Compare commits

..

11 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
trunksbomb
4daed48f0e add readme and screenshot
Some checks failed
Build / build (push) Has been cancelled
2026-03-25 02:45:00 -04:00
trunksbomb
a5a1293622 some more safety checks upon trade confirmation 2026-03-25 02:24:49 -04:00
trunksbomb
ebce4e852d implement trade x 2026-03-25 02:00:28 -04:00
trunksbomb
2c157be8cb add trade.log for server admins to review 2026-03-25 01:37:07 -04:00
trunksbomb
a5bc9789a3 hide trade debug commands by default, config option
add trade chat commands to ignore/unignore a player, initiate trade and accept/decline trade.
Add safety checks so a player doesn't get into a precarious situation and then get into a trade they initiated earlier.
Trade cancels if one or both players become unsafe
2026-03-25 01:10:48 -04:00
trunksbomb
b32a13ab84 UI cleanup, trade implementation via hotkey and via /trade player|yes|no 2026-03-24 22:31:25 -04:00
trunksbomb
31fd6c33ff UI work - trade is now a snapshot of the players' inventories at the time the trade was initiated and everything that happens on the trade menu is interacting with references to that snapshot. Inventory is validated at the end to make sure it still has the items that were in there when the trade started, and then the trade completes. 2026-03-24 21:27:32 -04:00
trunksbomb
ce3e83b928 stable build - trading works end to end with the /trade debug init with the full trade workflow in place. 2026-03-24 20:02:17 -04:00
27 changed files with 3578 additions and 317 deletions

273
README.md
View File

@@ -1,25 +1,260 @@
![screenshot1.png](screenshot1.png)
Installation information
=======
# Trade
This template repository can be directly cloned to get you started with a new
mod. Simply create a new repository cloned from this one, by following the
instructions provided by [GitHub](https://docs.github.com/en/repositories/creating-and-managing-repositories/creating-a-repository-from-a-template).
`Trade` is a NeoForge mod for Minecraft `1.21.1` that adds a secure player-to-player trading interface inspired by Runescape.
Once you have your clone, simply open the repository in the IDE of your choice. The usual recommendation for an IDE is either IntelliJ IDEA or Eclipse.
Players can request a trade, review both offers in a shared GUI, accept once to lock the offer, and confirm a second time to complete the exchange.
If at any point you are missing libraries in your IDE, or you've run into problems you can
run `gradlew --refresh-dependencies` to refresh the local cache. `gradlew clean` to reset everything
{this does not affect your code} and then start the process again.
## Features
Mapping Names:
============
By default, the MDK is configured to use the official mapping names from Mojang for methods and fields
in the Minecraft codebase. These names are covered by a specific license. All modders should be aware of this
license. For the latest license text, refer to the mapping file itself, or the reference copy here:
https://github.com/NeoForged/NeoForm/blob/main/Mojang.md
- Player-to-player trade requests with an accept / decline handshake
- Shared trade screen with separate offer areas for both players
- Two-step confirmation flow
- 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
- 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
Additional Resources:
==========
Community Documentation: https://docs.neoforged.net/
NeoForged Discord: https://discord.neoforged.net/
## Player Usage
### Starting a trade
Primary flow:
- Use the trade keybind (default: `G`) while looking at another player
Command option:
```mcfunction
/trade <player>
```
When a request is sent, the target player receives chat text with a clickable accept link.
They can also respond manually:
```mcfunction
/trade yes
/trade no
```
### In the trade screen
- Click an item in your inventory to trade `1`
- Right-click an item to open the quantity menu
- Use `Trade X` to enter a custom amount
- Click `Accept` when your offer is ready
- Once both players accept, the screen enters the confirmation stage
- 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
### Player commands
```mcfunction
/trade <player>
/trade yes
/trade no
/trade toggle
/trade on
/trade off
/trade ignore <player>
/trade unignore <player>
/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
- `/trade <player>` sends a trade request
- `/trade yes` accepts the current pending trade request
- `/trade no` declines the current pending trade request
- `/trade toggle` flips whether you accept incoming trade requests
- `/trade on` enables incoming trade requests
- `/trade off` disables incoming trade requests
- `/trade ignore <player>` blocks trade requests from that online player
- `/trade unignore <player>` removes that online player from your ignore list
- `/trade ignorelist` shows your ignored players
## Trade Safety
By default, the mod blocks trade requests or acceptance unless both players are in a safe state.
Default safeguards:
- on solid ground
- standing still
- not recently damaged
- not on fire
- not in liquid
- not sleeping
- not gliding
- not mounted
- in the same dimension
There is also a special delayed-accept flow for the most common transient failure:
- if the other player accepts while you are only moving or in the air, the request does not fail immediately
- instead, you get an on-screen countdown
- if you get safe in time and remain still long enough, the trade opens automatically
## Final Validation
When both players confirm, the trade is checked one last time before any real inventory is changed.
The trade verifies:
- both players are still in the same dimension, if that config is enabled
- both players are still within configured trade distance, if enabled
- both live inventories still match the snapshots taken when the trade started
- 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.
## Configuration
Server config values live under the `trade` section.
### Request / range
- `tradeCommandProximity`
- default: `0`
- `0` disables range checks
- `1+` requires players to be within that many blocks to request, accept, and finalize a trade
- `requestTimeoutSeconds`
- 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`
- default: `false`
- 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
- `requireOnGround`
- default: `true`
- `requireStationary`
- default: `true`
- `stationarySpeedThreshold`
- default: `0.03`
- `requireNoRecentDamage`
- default: `true`
- `noDamageSeconds`
- default: `10`
- `requireNotOnFire`
- default: `true`
- `requireNotInLiquid`
- default: `true`
- `requireNotSleeping`
- default: `true`
- `requireNotFallFlying`
- default: `true`
- `requireNotRiding`
- default: `true`
- `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:
```text
logs/trade.log
```
Entries include:
- trade requests
- request accepts / declines / expirations
- trade opens
- trade cancels
- trade completions
- player names
- UUIDs
- dimensions
- block coordinates
- items offered by each side
- final result text
## Debug Mode
Debug tools are disabled by default and only available when:
- `enableDebugFeatures = true`
When enabled, the mod exposes `/trade debug ...` commands and on-screen debug controls for single-client testing.

BIN
screenshot1.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 87 KiB

View File

@@ -1,6 +1,6 @@
package com.trunksbomb.trade.mod.client;
package com.trunksbomb.trade.client;
import com.trunksbomb.trade.mod.trade.TradeView;
import com.trunksbomb.trade.trade.TradeView;
import java.util.UUID;
import net.minecraft.client.Minecraft;
import net.minecraft.network.chat.Component;
@@ -18,6 +18,13 @@ public final class TradeClientState {
return minecraft;
}
public static void showOrUpdate(TradeView view) {
if (minecraft == null) {
minecraft = Minecraft.getInstance();
}
showOrUpdate(minecraft, view);
}
public static void showOrUpdate(Minecraft minecraftInstance, TradeView view) {
minecraft = minecraftInstance;
if (minecraft.screen instanceof TradeScreen screen && screen.sessionId().equals(view.sessionId())) {
@@ -32,14 +39,17 @@ public final class TradeClientState {
minecraft.setScreen(new TradeScreen(new TradeScreen.TradeMenu(0, minecraft.player.getInventory(), view), minecraft.player.getInventory(), Component.literal("Trade")));
}
public static void closeTrade(UUID sessionId, Component reason) {
if (minecraft == null) {
minecraft = Minecraft.getInstance();
}
closeTrade(minecraft, sessionId, reason);
}
public static void closeTrade(Minecraft minecraftInstance, UUID sessionId, Component reason) {
minecraft = minecraftInstance;
if (minecraft.screen instanceof TradeScreen screen && screen.sessionId().equals(sessionId)) {
minecraft.setScreen(null);
}
if (minecraft.player != null) {
minecraft.player.displayClientMessage(reason, false);
}
}
}

View File

@@ -1,14 +1,19 @@
package com.trunksbomb.trade.mod.client;
package com.trunksbomb.trade.client;
import com.trunksbomb.trade.mod.network.TradeActionPayload;
import com.trunksbomb.trade.mod.network.DebugTradeControlPayload;
import com.trunksbomb.trade.mod.trade.DebugControlAction;
import com.trunksbomb.trade.mod.trade.TradeAction;
import com.trunksbomb.trade.mod.trade.TradeStage;
import com.trunksbomb.trade.mod.trade.TradeView;
import com.trunksbomb.trade.network.TradeActionPayload;
import com.trunksbomb.trade.network.DebugTradeControlPayload;
import com.trunksbomb.trade.trade.DebugControlAction;
import com.trunksbomb.trade.trade.DebugUnsafeState;
import com.trunksbomb.trade.trade.TradeAction;
import com.trunksbomb.trade.trade.TradeStage;
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;
import net.minecraft.client.gui.screens.inventory.AbstractContainerScreen;
import net.minecraft.network.chat.Component;
import net.minecraft.resources.ResourceLocation;
@@ -30,8 +35,12 @@ public class TradeScreen extends AbstractContainerScreen<TradeScreen.TradeMenu>
private static final int OFFER_START_Y = 18;
private static final int INVENTORY_START_X = 62;
private static final int INVENTORY_START_Y = 139;
private static final int BANNER_X = 58;
private static final int BANNER_Y = 6;
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 STATUS_LABEL_Y = 51;
private static final int CONFIRM_LABEL_Y = 26;
private static final int ACCEPT_BUTTON_Y = 74;
private static final int CANCEL_BUTTON_Y = 98;
private static final int ACTION_BUTTON_WIDTH = 48;
@@ -40,10 +49,13 @@ public class TradeScreen extends AbstractContainerScreen<TradeScreen.TradeMenu>
private static final int DEBUG_BUTTON_WIDTH = 86;
private static final int DEBUG_BUTTON_HEIGHT = 20;
private static final int DEBUG_BUTTON_GAP = 4;
private static final int CONFIRM_PANEL_X = 89;
private static final int CONFIRM_PANEL_Y = 77;
private static final int CONFIRM_PANEL_WIDTH = 106;
private static final int CONFIRM_PANEL_HEIGHT = 64;
private static final int DEBUG_SMALL_BUTTON_WIDTH = 41;
private static final int AMOUNT_PROMPT_WIDTH = 90;
private static final int AMOUNT_PROMPT_HEIGHT = 44;
private ContextMenu contextMenu;
private EditBox amountInput;
private AmountPrompt amountPrompt;
private Button acceptButton;
public TradeScreen(TradeMenu menu, Inventory inventory, Component title) {
super(menu, inventory, title);
@@ -64,13 +76,19 @@ public class TradeScreen extends AbstractContainerScreen<TradeScreen.TradeMenu>
@Override
protected void init() {
super.init();
titleLabelX = LEFT_OFFER_START_X;
titleLabelY = 6;
titleLabelX = BANNER_X;
titleLabelY = BANNER_Y;
amountInput = new EditBox(font, leftPos + 0, topPos + 0, 54, 14, Component.literal("Trade Amount"));
amountInput.setVisible(false);
amountInput.setCanLoseFocus(false);
amountInput.setMaxLength(3);
amountInput.setFilter(value -> value.isEmpty() || value.chars().allMatch(Character::isDigit));
addRenderableWidget(amountInput);
addRenderableWidget(Button.builder(Component.literal("Accept"), button -> sendAction(TradeAction.ACCEPT, -1, false))
acceptButton = addRenderableWidget(Button.builder(acceptButtonLabel(), button -> sendAction(TradeAction.ACCEPT, -1, 1))
.bounds(leftPos + CENTER_COLUMN_X + 3, topPos + ACCEPT_BUTTON_Y, ACTION_BUTTON_WIDTH, ACTION_BUTTON_HEIGHT)
.build());
addRenderableWidget(Button.builder(Component.literal("Cancel"), button -> sendAction(TradeAction.DECLINE, -1, false))
addRenderableWidget(Button.builder(Component.literal("Cancel"), button -> sendAction(TradeAction.DECLINE, -1, 1))
.bounds(leftPos + CENTER_COLUMN_X + 3, topPos + CANCEL_BUTTON_Y, ACTION_BUTTON_WIDTH, ACTION_BUTTON_HEIGHT)
.build());
@@ -89,21 +107,49 @@ public class TradeScreen extends AbstractContainerScreen<TradeScreen.TradeMenu>
addRenderableWidget(Button.builder(Component.literal("Remove Item"), button -> sendDebug(DebugControlAction.REMOVE_LAST))
.bounds(debugX, debugY + 3 * (DEBUG_BUTTON_HEIGHT + DEBUG_BUTTON_GAP), DEBUG_BUTTON_WIDTH, DEBUG_BUTTON_HEIGHT)
.build());
int unsafeY = debugY + 4 * (DEBUG_BUTTON_HEIGHT + DEBUG_BUTTON_GAP) + 6;
addRenderableWidget(debugUnsafeButton("Damage", debugX, unsafeY, DebugUnsafeState.DAMAGE));
addRenderableWidget(debugUnsafeButton("Fire", debugX + DEBUG_SMALL_BUTTON_WIDTH + 4, unsafeY, DebugUnsafeState.FIRE));
addRenderableWidget(debugUnsafeButton("Liquid", debugX, unsafeY + DEBUG_BUTTON_HEIGHT + DEBUG_BUTTON_GAP, DebugUnsafeState.LIQUID));
addRenderableWidget(debugUnsafeButton("Moving", debugX + DEBUG_SMALL_BUTTON_WIDTH + 4, unsafeY + DEBUG_BUTTON_HEIGHT + DEBUG_BUTTON_GAP, DebugUnsafeState.MOVING));
addRenderableWidget(debugUnsafeButton("Sleep", debugX, unsafeY + 2 * (DEBUG_BUTTON_HEIGHT + DEBUG_BUTTON_GAP), DebugUnsafeState.SLEEPING));
addRenderableWidget(debugUnsafeButton("Ride", debugX + DEBUG_SMALL_BUTTON_WIDTH + 4, unsafeY + 2 * (DEBUG_BUTTON_HEIGHT + DEBUG_BUTTON_GAP), DebugUnsafeState.RIDING));
addRenderableWidget(debugUnsafeButton("Glide", debugX, unsafeY + 3 * (DEBUG_BUTTON_HEIGHT + DEBUG_BUTTON_GAP), DebugUnsafeState.GLIDING));
addRenderableWidget(Button.builder(Component.literal("Clear"), button -> sendDebug(DebugControlAction.CLEAR_UNSAFE))
.bounds(debugX + DEBUG_SMALL_BUTTON_WIDTH + 4, unsafeY + 3 * (DEBUG_BUTTON_HEIGHT + DEBUG_BUTTON_GAP), DEBUG_SMALL_BUTTON_WIDTH, DEBUG_BUTTON_HEIGHT)
.build());
}
}
@Override
public boolean keyPressed(int keyCode, int scanCode, int modifiers) {
if (amountPrompt != null) {
if (keyCode == 256) {
closeAmountPrompt();
return true;
}
if (keyCode == 257 || keyCode == 335) {
submitAmountPrompt();
return true;
}
if (amountInput != null && amountInput.keyPressed(keyCode, scanCode, modifiers)) {
return true;
}
}
if (minecraft != null && minecraft.options.keyInventory.matches(keyCode, scanCode)) {
onClose();
return true;
}
if (hoveredSlot != null) {
return true;
}
return super.keyPressed(keyCode, scanCode, modifiers);
}
@Override
public void onClose() {
sendAction(TradeAction.CLOSE, -1, false);
sendAction(TradeAction.CLOSE, -1, 1);
if (minecraft != null) {
minecraft.setScreen(null);
}
@@ -111,12 +157,37 @@ public class TradeScreen extends AbstractContainerScreen<TradeScreen.TradeMenu>
@Override
public boolean mouseClicked(double mouseX, double mouseY, int button) {
if (amountPrompt != null) {
if (isInsideAmountPrompt(mouseX, mouseY)) {
if (amountInput != null && amountInput.mouseClicked(mouseX, mouseY, button)) {
return true;
}
return true;
}
closeAmountPrompt();
return true;
}
if (contextMenu != null) {
if (handleContextMenuClick(mouseX, mouseY)) {
return true;
}
contextMenu = null;
}
if (menu.view().stage() == TradeStage.CONFIRMING) {
if (hoveredSlot != null) {
return true;
}
return super.mouseClicked(mouseX, mouseY, button);
}
if (hoveredSlot instanceof SelfOfferSlot selfOfferSlot) {
sendAction(TradeAction.REMOVE_ITEM, selfOfferSlot.offerIndex(), button == 1);
if (button == 1) {
openContextMenu(mouseX, mouseY, TradeAction.REMOVE_ITEM, selfOfferSlot.offerIndex(), hoveredSlot.getItem(), "Remove");
} else {
sendAction(TradeAction.REMOVE_ITEM, selfOfferSlot.offerIndex(), 1);
}
return true;
}
@@ -124,8 +195,18 @@ public class TradeScreen extends AbstractContainerScreen<TradeScreen.TradeMenu>
return true;
}
if (hoveredSlot != null && hoveredSlot.container == menu.playerInventory()) {
sendAction(TradeAction.ADD_ITEM, hoveredSlot.getSlotIndex(), button == 1);
if (hoveredSlot instanceof GhostInventorySlot ghostInventorySlot) {
if (button == 1) {
openContextMenu(mouseX, mouseY, TradeAction.ADD_ITEM, ghostInventorySlot.inventoryIndex(), hoveredSlot.getItem(), "Trade");
} else if (hasShiftDown()) {
sendAction(TradeAction.ADD_ITEM, ghostInventorySlot.inventoryIndex(), hoveredSlot.getItem().getCount());
} else {
sendAction(TradeAction.ADD_ITEM, ghostInventorySlot.inventoryIndex(), 1);
}
return true;
}
if (hoveredSlot != null) {
return true;
}
@@ -135,55 +216,150 @@ public class TradeScreen extends AbstractContainerScreen<TradeScreen.TradeMenu>
@Override
protected void renderBg(GuiGraphics guiGraphics, float partialTick, int mouseX, int mouseY) {
guiGraphics.blit(TEXTURE, leftPos, topPos, 0, 0, imageWidth, imageHeight, imageWidth, imageHeight);
if (menu.view().stage() == TradeStage.CONFIRMING) {
renderConfirmationOverlay(guiGraphics);
}
}
@Override
protected void renderLabels(GuiGraphics guiGraphics, int mouseX, int mouseY) {
guiGraphics.drawString(font, menu.view().selfName(), titleLabelX, titleLabelY, 0x404040, false);
guiGraphics.drawString(font, menu.view().otherName(), RIGHT_OFFER_START_X, 6, 0x404040, false);
guiGraphics.drawString(font, "Trading with " + menu.view().otherName(), titleLabelX, titleLabelY, 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) {
drawScaledCenteredColumnText(guiGraphics, "Are you", CONFIRM_LABEL_Y, 0xB02020, 0.7F);
drawScaledCenteredColumnText(guiGraphics, "sure you want", CONFIRM_LABEL_Y + 7, 0xB02020, 0.7F);
drawScaledCenteredColumnText(guiGraphics, "to trade?", CONFIRM_LABEL_Y + 14, 0xB02020, 0.7F);
}
if (menu.view().otherAccepted()) {
drawScaledCenteredColumnText(guiGraphics, "Other player", STATUS_LABEL_Y, 0x2E7D32, 0.7F);
drawScaledCenteredColumnText(guiGraphics, "has accepted", STATUS_LABEL_Y + 7, 0x2E7D32, 0.7F);
}
if (menu.view().stage() == TradeStage.CONFIRMING) {
drawCenteredColumnText(guiGraphics, "FINAL", 16, 0x7A4F00);
drawCenteredColumnText(guiGraphics, "CONFIRM", 28, 0x7A4F00);
} else if (menu.view().selfAccepted()) {
if (menu.view().stage() == TradeStage.CONFIRMING) {
drawScaledCenteredColumnText(guiGraphics, "Waiting for", STATUS_LABEL_Y, 0x2E7D32, 0.7F);
drawScaledCenteredColumnText(guiGraphics, "other player", STATUS_LABEL_Y + 7, 0x2E7D32, 0.7F);
drawScaledCenteredColumnText(guiGraphics, "to confirm", STATUS_LABEL_Y + 14, 0x2E7D32, 0.7F);
} else {
drawScaledCenteredColumnText(guiGraphics, "Waiting for", STATUS_LABEL_Y, 0x2E7D32, 0.7F);
drawScaledCenteredColumnText(guiGraphics, "other player", STATUS_LABEL_Y + 7, 0x2E7D32, 0.7F);
drawScaledCenteredColumnText(guiGraphics, "to accept", STATUS_LABEL_Y + 14, 0x2E7D32, 0.7F);
}
}
}
@Override
public void render(GuiGraphics guiGraphics, int mouseX, int mouseY, float partialTick) {
if (acceptButton != null) {
acceptButton.setMessage(acceptButtonLabel());
}
super.render(guiGraphics, mouseX, mouseY, partialTick);
renderProhibitedSlotWarnings(guiGraphics);
renderChangedSlotWarnings(guiGraphics);
renderContextMenu(guiGraphics, mouseX, mouseY);
renderAmountPrompt(guiGraphics);
renderTradeTooltip(guiGraphics, mouseX, mouseY);
}
private Component acceptButtonLabel() {
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, boolean secondary) {
private void sendAction(TradeAction action, int slot, int amount) {
if (minecraft == null || minecraft.getConnection() == null) {
return;
}
PacketDistributor.sendToServer(new TradeActionPayload(menu.view().sessionId(), action, slot, secondary));
PacketDistributor.sendToServer(new TradeActionPayload(menu.view().sessionId(), action, slot, amount));
}
private void sendDebug(DebugControlAction action) {
sendDebug(action, "");
}
private void sendDebug(DebugControlAction action, String spec) {
if (minecraft == null || minecraft.getConnection() == null) {
return;
}
PacketDistributor.sendToServer(new DebugTradeControlPayload(menu.view().sessionId(), action, ""));
PacketDistributor.sendToServer(new DebugTradeControlPayload(menu.view().sessionId(), action, spec));
}
private void renderConfirmationOverlay(GuiGraphics guiGraphics) {
int panelLeft = leftPos + CONFIRM_PANEL_X;
int panelTop = topPos + CONFIRM_PANEL_Y;
guiGraphics.fill(leftPos, topPos, leftPos + imageWidth, topPos + imageHeight, 0x22000000);
guiGraphics.fill(panelLeft, panelTop, panelLeft + CONFIRM_PANEL_WIDTH, panelTop + CONFIRM_PANEL_HEIGHT, 0xDDF7D48C);
guiGraphics.fill(panelLeft + 1, panelTop + 1, panelLeft + CONFIRM_PANEL_WIDTH - 1, panelTop + CONFIRM_PANEL_HEIGHT - 1, 0xAAFFF4D6);
guiGraphics.drawString(font, Component.literal("Confirm Trade"), panelLeft + 11, panelTop + 9, 0x5B3A00, false);
guiGraphics.drawString(font, Component.literal("Review items"), panelLeft + 18, panelTop + 24, 0x5B3A00, false);
guiGraphics.drawString(font, Component.literal("No more changes"), panelLeft + 9, panelTop + 36, 0x5B3A00, false);
private Button debugUnsafeButton(String label, int x, int y, DebugUnsafeState state) {
return Button.builder(Component.literal(label), button -> sendDebug(DebugControlAction.SET_UNSAFE, state.name()))
.bounds(x, y, DEBUG_SMALL_BUTTON_WIDTH, DEBUG_BUTTON_HEIGHT)
.build();
}
private void drawCenteredColumnText(GuiGraphics guiGraphics, String text, int y, int color) {
@@ -202,19 +378,205 @@ public class TradeScreen extends AbstractContainerScreen<TradeScreen.TradeMenu>
pose.popPose();
}
private void openContextMenu(double mouseX, double mouseY, TradeAction action, int slot, ItemStack stack, String verb) {
if (stack.isEmpty()) {
return;
}
List<ContextOption> options = new ArrayList<>();
for (int amount : contextAmounts(stack.getMaxStackSize())) {
options.add(new ContextOption(verb + " " + amount, amount));
}
options.add(new ContextOption(verb + " X", -1));
contextMenu = new ContextMenu(action, slot, options, (int) mouseX, (int) mouseY);
}
private boolean handleContextMenuClick(double mouseX, double mouseY) {
if (contextMenu == null) {
return false;
}
int menuX = contextMenuX();
int menuY = contextMenuY();
int menuWidth = contextMenuWidth();
int optionHeight = 12;
int menuHeight = contextMenu.options().size() * optionHeight + 4;
if (mouseX < menuX || mouseX >= menuX + menuWidth || mouseY < menuY || mouseY >= menuY + menuHeight) {
return false;
}
int index = ((int) mouseY - menuY - 2) / optionHeight;
if (index < 0 || index >= contextMenu.options().size()) {
return true;
}
ContextOption option = contextMenu.options().get(index);
if (option.amount() < 0) {
openAmountPrompt(contextMenu.action(), contextMenu.slot(), contextMenu.mouseX(), contextMenu.mouseY());
} else {
sendAction(contextMenu.action(), contextMenu.slot(), option.amount());
}
contextMenu = null;
return true;
}
private void renderContextMenu(GuiGraphics guiGraphics, int mouseX, int mouseY) {
if (contextMenu == null) {
return;
}
var pose = guiGraphics.pose();
pose.pushPose();
pose.translate(0.0F, 0.0F, 400.0F);
int menuX = contextMenuX();
int menuY = contextMenuY();
int menuWidth = contextMenuWidth();
int optionHeight = 12;
int menuHeight = contextMenu.options().size() * optionHeight + 4;
guiGraphics.fill(menuX, menuY, menuX + menuWidth, menuY + menuHeight, 0xF0101010);
guiGraphics.fill(menuX + 1, menuY + 1, menuX + menuWidth - 1, menuY + menuHeight - 1, 0xFF2B2B2B);
for (int i = 0; i < contextMenu.options().size(); i++) {
int optionY = menuY + 2 + i * optionHeight;
boolean hovered = mouseX >= menuX + 2 && mouseX < menuX + menuWidth - 2 && mouseY >= optionY && mouseY < optionY + optionHeight;
if (hovered) {
guiGraphics.fill(menuX + 2, optionY, menuX + menuWidth - 2, optionY + optionHeight, 0xFF5B6EE1);
}
guiGraphics.drawString(font, contextMenu.options().get(i).label(), menuX + 6, optionY + 2, 0xFFFFFF, false);
}
pose.popPose();
}
private void openAmountPrompt(TradeAction action, int slot, int mouseX, int mouseY) {
amountPrompt = new AmountPrompt(action, slot, mouseX, mouseY);
if (amountInput != null) {
amountInput.setValue("");
amountInput.setFocused(true);
amountInput.setVisible(true);
setFocused(amountInput);
}
}
private void submitAmountPrompt() {
if (amountPrompt == null || amountInput == null) {
return;
}
String value = amountInput.getValue().trim();
if (value.isEmpty()) {
return;
}
try {
int amount = Integer.parseInt(value);
if (amount > 0) {
sendAction(amountPrompt.action(), amountPrompt.slot(), amount);
closeAmountPrompt();
}
} catch (NumberFormatException ignored) {
// Filter should prevent this, but ignore invalid input safely.
}
}
private void closeAmountPrompt() {
amountPrompt = null;
if (amountInput != null) {
amountInput.setValue("");
amountInput.setFocused(false);
amountInput.setVisible(false);
}
setFocused(null);
}
private void renderAmountPrompt(GuiGraphics guiGraphics) {
if (amountPrompt == null || amountInput == null) {
return;
}
var pose = guiGraphics.pose();
pose.pushPose();
pose.translate(0.0F, 0.0F, 450.0F);
int promptX = amountPromptX();
int promptY = amountPromptY();
guiGraphics.fill(promptX, promptY, promptX + AMOUNT_PROMPT_WIDTH, promptY + AMOUNT_PROMPT_HEIGHT, 0xF0101010);
guiGraphics.fill(promptX + 1, promptY + 1, promptX + AMOUNT_PROMPT_WIDTH - 1, promptY + AMOUNT_PROMPT_HEIGHT - 1, 0xFF2B2B2B);
guiGraphics.drawString(font, amountPrompt.action() == TradeAction.ADD_ITEM ? "Trade amount" : "Remove amount", promptX + 6, promptY + 6, 0xFFFFFF, false);
guiGraphics.drawString(font, "Enter to confirm", promptX + 6, promptY + 30, 0xAAAAAA, false);
amountInput.setPosition(promptX + 6, promptY + 16);
amountInput.render(guiGraphics, 0, 0, 0.0F);
pose.popPose();
}
private boolean isInsideAmountPrompt(double mouseX, double mouseY) {
int promptX = amountPromptX();
int promptY = amountPromptY();
return mouseX >= promptX && mouseX < promptX + AMOUNT_PROMPT_WIDTH && mouseY >= promptY && mouseY < promptY + AMOUNT_PROMPT_HEIGHT;
}
private int amountPromptX() {
int preferredX = amountPrompt == null ? leftPos + 8 : Math.min(amountPrompt.mouseX(), this.width - AMOUNT_PROMPT_WIDTH - 4);
return Math.max(4, preferredX);
}
private int amountPromptY() {
int preferredY = amountPrompt == null ? topPos + 8 : Math.min(amountPrompt.mouseY(), this.height - AMOUNT_PROMPT_HEIGHT - 4);
return Math.max(4, preferredY);
}
private int contextMenuX() {
int width = contextMenuWidth();
return Math.min(contextMenu.mouseX(), this.width - width - 4);
}
private int contextMenuY() {
int height = contextMenu.options().size() * 12 + 4;
return Math.min(contextMenu.mouseY(), this.height - height - 4);
}
private int contextMenuWidth() {
int width = 0;
for (ContextOption option : contextMenu.options()) {
width = Math.max(width, font.width(option.label()));
}
return width + 12;
}
private static int[] contextAmounts(int maxStackSize) {
if (maxStackSize == 1) {
return new int[] {1};
}
if (maxStackSize == 16) {
return new int[] {1, 2, 4, 8, 16};
}
if (maxStackSize == 32) {
return new int[] {1, 4, 8, 16, 32};
}
return new int[] {1, 8, 16, 32, 64};
}
private record ContextMenu(TradeAction action, int slot, List<ContextOption> options, int mouseX, int mouseY) {}
private record ContextOption(String label, int amount) {}
private record AmountPrompt(TradeAction action, int slot, int mouseX, int mouseY) {}
public static class TradeMenu extends AbstractContainerMenu {
private static final int OFFER_COLUMNS = 6;
private static final int OFFER_ROWS = 6;
private TradeView view;
private final Inventory playerInventory;
private final SimpleContainer inventoryDisplay = new SimpleContainer(TradeView.INVENTORY_SLOT_COUNT);
private final SimpleContainer selfOffer = new SimpleContainer(TradeView.OFFER_SLOT_COUNT);
private final SimpleContainer otherOffer = new SimpleContainer(TradeView.OFFER_SLOT_COUNT);
public TradeMenu(int containerId, Inventory inventory, TradeView view) {
super(MenuType.GENERIC_9x6, containerId);
this.playerInventory = inventory;
this.view = view;
syncOfferContainers();
syncContainers();
for (int row = 0; row < OFFER_ROWS; row++) {
for (int col = 0; col < OFFER_COLUMNS; col++) {
@@ -233,12 +595,12 @@ public class TradeScreen extends AbstractContainerScreen<TradeScreen.TradeMenu>
for (int row = 0; row < 3; row++) {
for (int col = 0; col < 9; col++) {
int slot = col + row * 9 + 9;
addSlot(new Slot(inventory, slot, INVENTORY_START_X + col * 18, INVENTORY_START_Y + row * 18));
addSlot(new GhostInventorySlot(inventoryDisplay, slot, INVENTORY_START_X + col * 18, INVENTORY_START_Y + row * 18));
}
}
for (int col = 0; col < 9; col++) {
addSlot(new Slot(inventory, col, INVENTORY_START_X + col * 18, INVENTORY_START_Y + 58));
addSlot(new GhostInventorySlot(inventoryDisplay, col, INVENTORY_START_X + col * 18, INVENTORY_START_Y + 58));
}
}
@@ -246,16 +608,15 @@ public class TradeScreen extends AbstractContainerScreen<TradeScreen.TradeMenu>
return view;
}
public Inventory playerInventory() {
return playerInventory;
}
public void updateView(TradeView view) {
this.view = view;
syncOfferContainers();
syncContainers();
}
private void syncOfferContainers() {
private void syncContainers() {
for (int i = 0; i < TradeView.INVENTORY_SLOT_COUNT; i++) {
inventoryDisplay.setItem(i, view.inventory().get(i).copy());
}
for (int i = 0; i < TradeView.OFFER_SLOT_COUNT; i++) {
selfOffer.setItem(i, view.selfOffer().get(i).copy());
otherOffer.setItem(i, view.otherOffer().get(i).copy());
@@ -298,6 +659,30 @@ public class TradeScreen extends AbstractContainerScreen<TradeScreen.TradeMenu>
super(container, slot, x, y);
}
public int offerIndex() {
return getSlotIndex();
}
@Override
public boolean mayPickup(Player player) {
return false;
}
@Override
public boolean mayPlace(ItemStack stack) {
return false;
}
}
private static class GhostInventorySlot extends Slot {
public GhostInventorySlot(Container container, int slot, int x, int y) {
super(container, slot, x, y);
}
public int inventoryIndex() {
return getSlotIndex();
}
@Override
public boolean mayPickup(Player player) {
return false;

View File

@@ -1,23 +1,36 @@
package com.trunksbomb.trade.mod.command;
package com.trunksbomb.trade.command;
import com.mojang.brigadier.CommandDispatcher;
import com.mojang.brigadier.arguments.IntegerArgumentType;
import com.mojang.brigadier.arguments.StringArgumentType;
import com.mojang.brigadier.suggestion.SuggestionsBuilder;
import com.trunksbomb.trade.mod.trade.DebugTradeSession;
import com.trunksbomb.trade.mod.trade.TradeManager;
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 {
private TradeCommand() {}
public static void register(CommandDispatcher<CommandSourceStack> dispatcher) {
dispatcher.register(Commands.literal("trade")
var trade = Commands.literal("trade")
.then(Commands.literal("debug")
.then(Commands.literal("init").executes(context -> initDebug(context.getSource())))
.requires(source -> TradeConfig.enableDebugFeaturesSafe())
.then(Commands.literal("init")
.executes(context -> initDebug(context.getSource(), 0))
.then(Commands.argument("time", IntegerArgumentType.integer(0))
.executes(context -> initDebug(context.getSource(), IntegerArgumentType.getInteger(context, "time")))))
.then(Commands.literal("request")
.executes(context -> requestDebug(context.getSource(), 0))
.then(Commands.argument("time", IntegerArgumentType.integer(0))
.executes(context -> requestDebug(context.getSource(), IntegerArgumentType.getInteger(context, "time")))))
.then(Commands.literal("offer")
.then(Commands.argument("spec", StringArgumentType.greedyString())
.executes(context -> setDebugOffer(
@@ -28,12 +41,75 @@ public final class TradeCommand {
.executes(context -> removeDebugOffer(
context.getSource(),
StringArgumentType.getString(context, "spec")))))
.then(Commands.literal("unsafe")
.then(Commands.literal("damage").executes(context -> setDebugUnsafe(context.getSource(), DebugUnsafeState.DAMAGE, "damage")))
.then(Commands.literal("fire").executes(context -> setDebugUnsafe(context.getSource(), DebugUnsafeState.FIRE, "fire")))
.then(Commands.literal("liquid").executes(context -> setDebugUnsafe(context.getSource(), DebugUnsafeState.LIQUID, "liquid")))
.then(Commands.literal("moving").executes(context -> setDebugUnsafe(context.getSource(), DebugUnsafeState.MOVING, "moving")))
.then(Commands.literal("sleeping").executes(context -> setDebugUnsafe(context.getSource(), DebugUnsafeState.SLEEPING, "sleeping")))
.then(Commands.literal("riding").executes(context -> setDebugUnsafe(context.getSource(), DebugUnsafeState.RIDING, "riding")))
.then(Commands.literal("gliding").executes(context -> setDebugUnsafe(context.getSource(), DebugUnsafeState.GLIDING, "gliding")))
.then(Commands.literal("clear").executes(context -> clearDebugUnsafe(context.getSource()))))
.then(Commands.literal("accept").executes(context -> acceptDebug(context.getSource())))
.then(Commands.literal("cancel").executes(context -> cancelDebug(context.getSource())))
.then(Commands.literal("close").executes(context -> closeDebug(context.getSource()))))
.then(Commands.literal("close").executes(context -> closeDebug(context.getSource()))));
dispatcher.register(trade
.then(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())))
.then(Commands.literal("on").executes(context -> setTradeEnabled(context.getSource(), true)))
.then(Commands.literal("off").executes(context -> setTradeEnabled(context.getSource(), false)))
.then(Commands.literal("ignore")
.then(Commands.argument("player", StringArgumentType.word())
.suggests((context, builder) -> suggestPlayers(context.getSource(), builder))
.executes(context -> ignorePlayer(context.getSource(), StringArgumentType.getString(context, "player")))))
.then(Commands.literal("unignore")
.then(Commands.argument("player", StringArgumentType.word())
.suggests((context, builder) -> suggestPlayers(context.getSource(), builder))
.executes(context -> unignorePlayer(context.getSource(), StringArgumentType.getString(context, "player")))))
.then(Commands.literal("ignorelist").executes(context -> listIgnoredPlayers(context.getSource())))
.then(Commands.argument("player", StringArgumentType.word())
.suggests((context, builder) -> suggestPlayers(context.getSource(), builder))
.executes(context -> startTrade(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) {
@@ -45,7 +121,7 @@ public final class TradeCommand {
return builder.buildFuture();
}
private static int startTrade(CommandSourceStack source, String targetName) {
private static int requestTrade(CommandSourceStack source, String targetName) {
if (!(source.getEntity() instanceof ServerPlayer player)) {
source.sendFailure(Component.literal("Only players can start trades."));
return 0;
@@ -62,27 +138,297 @@ public final class TradeCommand {
return 0;
}
if (!TradeManager.get(source.getServer()).startTrade(player, target)) {
source.sendFailure(Component.literal("Trade could not be started. One of you is already trading."));
if (!TradeManager.get(source.getServer()).requestTrade(player, target)) {
return 0;
}
source.sendSuccess(() -> Component.literal("Trade opened with " + target.getGameProfile().getName() + "."), false);
return 1;
}
private static int initDebug(CommandSourceStack source) {
private static int respondTrade(CommandSourceStack source, boolean accept) {
if (!(source.getEntity() instanceof ServerPlayer player)) {
source.sendFailure(Component.literal("Only players can answer trade requests."));
return 0;
}
boolean result = accept
? acceptPendingTrade(player, source)
: TradeManager.get(source.getServer()).declinePendingTrade(player);
return result ? 1 : 0;
}
private static boolean acceptPendingTrade(ServerPlayer player, CommandSourceStack source) {
TradeManager manager = TradeManager.get(source.getServer());
if (manager.hasPendingTradeRequest(player)) {
return manager.acceptPendingTrade(player);
}
if (manager.hasPendingDebugTradeRequest(player)) {
return manager.acceptPendingDebugTrade(player);
}
player.sendSystemMessage(Component.literal("You have no pending trade request."));
return false;
}
private static int toggleTrade(CommandSourceStack source) {
if (!(source.getEntity() instanceof ServerPlayer player)) {
source.sendFailure(Component.literal("Only players can toggle trade requests."));
return 0;
}
boolean enabled = TradeManager.get(source.getServer()).toggleTradeEnabled(player);
source.sendSuccess(() -> Component.literal("Trade requests are now " + (enabled ? "enabled" : "disabled") + "."), false);
return 1;
}
private static int setTradeEnabled(CommandSourceStack source, boolean enabled) {
if (!(source.getEntity() instanceof ServerPlayer player)) {
source.sendFailure(Component.literal("Only players can change trade request settings."));
return 0;
}
TradeManager.get(source.getServer()).setTradeEnabled(player, enabled);
source.sendSuccess(() -> Component.literal("Trade requests are now " + (enabled ? "enabled" : "disabled") + "."), false);
return 1;
}
private static int ignorePlayer(CommandSourceStack source, String targetName) {
if (!(source.getEntity() instanceof ServerPlayer player)) {
source.sendFailure(Component.literal("Only players can manage ignored traders."));
return 0;
}
ServerPlayer target = source.getServer().getPlayerList().getPlayerByName(targetName);
if (target == null) {
source.sendFailure(Component.literal("That player is not online."));
return 0;
}
if (target == player) {
source.sendFailure(Component.literal("You cannot ignore yourself."));
return 0;
}
if (!TradeManager.get(source.getServer()).addIgnoredPlayer(player, target)) {
source.sendFailure(Component.literal(target.getGameProfile().getName() + " is already ignored."));
return 0;
}
source.sendSuccess(() -> Component.literal("You are now ignoring trade requests from " + target.getGameProfile().getName() + "."), false);
return 1;
}
private static int unignorePlayer(CommandSourceStack source, String targetName) {
if (!(source.getEntity() instanceof ServerPlayer player)) {
source.sendFailure(Component.literal("Only players can manage ignored traders."));
return 0;
}
ServerPlayer target = source.getServer().getPlayerList().getPlayerByName(targetName);
if (target == null) {
source.sendFailure(Component.literal("That player is not online."));
return 0;
}
if (!TradeManager.get(source.getServer()).removeIgnoredPlayer(player, target)) {
source.sendFailure(Component.literal(target.getGameProfile().getName() + " is not on your ignore list."));
return 0;
}
source.sendSuccess(() -> Component.literal("You are no longer ignoring trade requests from " + target.getGameProfile().getName() + "."), false);
return 1;
}
private static int listIgnoredPlayers(CommandSourceStack source) {
if (!(source.getEntity() instanceof ServerPlayer player)) {
source.sendFailure(Component.literal("Only players can manage ignored traders."));
return 0;
}
java.util.List<String> ignored = TradeManager.get(source.getServer()).ignoredPlayerNames(player);
if (ignored.isEmpty()) {
source.sendSuccess(() -> Component.literal("Your trade ignore list is empty."), false);
return 1;
}
source.sendSuccess(() -> Component.literal("Ignored traders: " + String.join(", ", ignored)), false);
return 1;
}
private static int 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) {
if (!(source.getEntity() instanceof ServerPlayer player)) {
source.sendFailure(Component.literal("Only players can start debug trades."));
return 0;
}
if (!TradeManager.get(source.getServer()).initDebugTrade(player)) {
if (!TradeManager.get(source.getServer()).startDebugInit(player, delaySeconds)) {
source.sendFailure(Component.literal("You are already in a trade."));
return 0;
}
source.sendSuccess(() -> Component.literal("Debug trade initialized."), false);
if (delaySeconds <= 0) {
source.sendSuccess(() -> Component.literal("Debug trade request sent and the debug trader will accept immediately."), false);
} else {
source.sendSuccess(() -> Component.literal("Debug trade request sent and the debug trader will accept in " + delaySeconds + " seconds."), false);
}
return 1;
}
private static int requestDebug(CommandSourceStack source, int delaySeconds) {
if (!(source.getEntity() instanceof ServerPlayer player)) {
source.sendFailure(Component.literal("Only players can start debug trades."));
return 0;
}
if (!TradeManager.get(source.getServer()).scheduleDebugRequest(player, delaySeconds, false)) {
source.sendFailure(Component.literal("You are already in a trade."));
return 0;
}
if (delaySeconds <= 0) {
source.sendSuccess(() -> Component.literal("Debug trade request sent."), false);
} else {
source.sendSuccess(() -> Component.literal("Debug trade request scheduled in " + delaySeconds + " seconds."), false);
}
return 1;
}
@@ -94,7 +440,7 @@ public final class TradeCommand {
try {
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;
}
} catch (IllegalArgumentException exception) {
@@ -119,6 +465,10 @@ public final class TradeCommand {
try {
int result = TradeManager.get(source.getServer()).removeDebugOffer(player, spec);
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."));
return 0;
}
@@ -143,6 +493,36 @@ public final class TradeCommand {
return runDebugAction(source, TradeManager.get(source.getServer())::closeDebug, "Debug trade closed.");
}
private static int setDebugUnsafe(CommandSourceStack source, DebugUnsafeState state, String label) {
if (!(source.getEntity() instanceof ServerPlayer player)) {
source.sendFailure(Component.literal("Only players can control debug trades."));
return 0;
}
if (!TradeManager.get(source.getServer()).setDebugUnsafeState(player, state)) {
source.sendFailure(Component.literal("Start a debug trade first with /trade debug init."));
return 0;
}
source.sendSuccess(() -> Component.literal("Debug unsafe state enabled: " + label + "."), false);
return 1;
}
private static int clearDebugUnsafe(CommandSourceStack source) {
if (!(source.getEntity() instanceof ServerPlayer player)) {
source.sendFailure(Component.literal("Only players can control debug trades."));
return 0;
}
if (!TradeManager.get(source.getServer()).clearDebugUnsafeStates(player)) {
source.sendFailure(Component.literal("Start a debug trade first with /trade debug init."));
return 0;
}
source.sendSuccess(() -> Component.literal("Debug unsafe states cleared."), false);
return 1;
}
private static int runDebugAction(CommandSourceStack source, java.util.function.Predicate<ServerPlayer> action, String successMessage) {
if (!(source.getEntity() instanceof ServerPlayer player)) {
source.sendFailure(Component.literal("Only players can control debug trades."));
@@ -157,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) {}
}

View File

@@ -0,0 +1,31 @@
package com.trunksbomb.trade.mod;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.StandardOpenOption;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import net.minecraft.server.MinecraftServer;
import net.neoforged.fml.loading.FMLPaths;
public final class TradeAuditLog {
private static final DateTimeFormatter TIMESTAMP_FORMAT = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
private static final Object LOCK = new Object();
private TradeAuditLog() {}
public static void log(MinecraftServer server, String message) {
Path logPath = FMLPaths.GAMEDIR.get().resolve("logs").resolve("trade.log");
String line = "[" + LocalDateTime.now().format(TIMESTAMP_FORMAT) + "] " + message + System.lineSeparator();
synchronized (LOCK) {
try {
Files.createDirectories(logPath.getParent());
Files.writeString(logPath, line, StandardOpenOption.CREATE, StandardOpenOption.APPEND);
} catch (IOException exception) {
TradeMod.LOGGER.warn("Failed to write trade audit log entry", exception);
}
}
}
}

View File

@@ -1,18 +1,52 @@
package com.trunksbomb.trade.mod;
import com.trunksbomb.trade.mod.client.TradeClientState;
import com.mojang.blaze3d.platform.InputConstants;
import com.trunksbomb.trade.client.TradeClientState;
import com.trunksbomb.trade.network.TradeRequestPayload;
import net.minecraft.client.KeyMapping;
import net.minecraft.client.Minecraft;
import net.minecraft.network.chat.Component;
import net.minecraft.world.entity.player.Player;
import net.neoforged.api.distmarker.Dist;
import net.neoforged.bus.api.SubscribeEvent;
import net.neoforged.fml.common.EventBusSubscriber;
import net.neoforged.bus.api.IEventBus;
import net.neoforged.fml.common.Mod;
import net.neoforged.fml.event.lifecycle.FMLClientSetupEvent;
import net.neoforged.neoforge.client.event.ClientTickEvent;
import net.neoforged.neoforge.client.event.RegisterKeyMappingsEvent;
import net.neoforged.neoforge.common.NeoForge;
import net.neoforged.neoforge.network.PacketDistributor;
import org.lwjgl.glfw.GLFW;
@Mod(value = TradeMod.MODID, dist = Dist.CLIENT)
@EventBusSubscriber(modid = TradeMod.MODID, value = Dist.CLIENT)
public class TradeClientMod {
@SubscribeEvent
static void onClientSetup(FMLClientSetupEvent event) {
event.enqueueWork(() -> TradeClientState.init(Minecraft.getInstance()));
private static final KeyMapping REQUEST_TRADE = new KeyMapping(
"key.trade.request_trade",
InputConstants.Type.KEYSYM,
GLFW.GLFW_KEY_G,
"key.categories.gameplay");
public TradeClientMod(IEventBus modEventBus) {
modEventBus.addListener(this::registerKeyMappings);
NeoForge.EVENT_BUS.addListener(this::onClientTick);
TradeClientState.init(Minecraft.getInstance());
}
private void registerKeyMappings(RegisterKeyMappingsEvent event) {
event.register(REQUEST_TRADE);
}
private void onClientTick(ClientTickEvent.Post event) {
Minecraft minecraft = Minecraft.getInstance();
while (REQUEST_TRADE.consumeClick()) {
if (minecraft.player == null || minecraft.screen != null) {
continue;
}
if (!(minecraft.crosshairPickEntity instanceof Player target) || target == minecraft.player) {
minecraft.player.displayClientMessage(Component.literal("Look at a player to send a trade request."), true);
continue;
}
PacketDistributor.sendToServer(new TradeRequestPayload(target.getId()));
}
}
}

View File

@@ -0,0 +1,197 @@
package com.trunksbomb.trade.mod;
import net.neoforged.neoforge.common.ModConfigSpec;
public final class TradeConfig {
public static final ModConfigSpec SPEC;
private static final ModConfigSpec.IntValue TRADE_COMMAND_PROXIMITY;
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;
private static final ModConfigSpec.BooleanValue REQUIRE_ON_GROUND;
private static final ModConfigSpec.BooleanValue REQUIRE_STATIONARY;
private static final ModConfigSpec.DoubleValue STATIONARY_SPEED_THRESHOLD;
private static final ModConfigSpec.BooleanValue REQUIRE_NO_RECENT_DAMAGE;
private static final ModConfigSpec.IntValue NO_DAMAGE_SECONDS;
private static final ModConfigSpec.BooleanValue REQUIRE_NOT_ON_FIRE;
private static final ModConfigSpec.BooleanValue REQUIRE_NOT_IN_LIQUID;
private static final ModConfigSpec.BooleanValue REQUIRE_NOT_SLEEPING;
private static final ModConfigSpec.BooleanValue REQUIRE_NOT_FALL_FLYING;
private static final ModConfigSpec.BooleanValue REQUIRE_NOT_RIDING;
private static final ModConfigSpec.BooleanValue REQUIRE_SAME_DIMENSION;
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 {
ModConfigSpec.Builder builder = new ModConfigSpec.Builder();
builder.push("trade");
TRADE_COMMAND_PROXIMITY = builder
.comment("Maximum distance in blocks to initiate or accept a trade request. 0 disables the distance check.")
.defineInRange("tradeCommandProximity", 0, 0, 1024);
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.")
.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.")
.define("requireOnGround", true);
REQUIRE_STATIONARY = builder.comment("Require players to be stationary before requesting or accepting a trade.")
.define("requireStationary", true);
STATIONARY_SPEED_THRESHOLD = builder.comment("Maximum horizontal player speed considered stationary.")
.defineInRange("stationarySpeedThreshold", 0.03D, 0.0D, 10.0D);
REQUIRE_NO_RECENT_DAMAGE = builder.comment("Require players to have taken no damage recently before requesting or accepting a trade.")
.define("requireNoRecentDamage", true);
NO_DAMAGE_SECONDS = builder.comment("How long a player must be out of combat to trade when requireNoRecentDamage is enabled.")
.defineInRange("noDamageSeconds", 10, 0, 3600);
REQUIRE_NOT_ON_FIRE = builder.comment("Require players to not be on fire before requesting or accepting a trade.")
.define("requireNotOnFire", true);
REQUIRE_NOT_IN_LIQUID = builder.comment("Require players to not be in water or lava before requesting or accepting a trade.")
.define("requireNotInLiquid", true);
REQUIRE_NOT_SLEEPING = builder.comment("Require players to not be sleeping before requesting or accepting a trade.")
.define("requireNotSleeping", true);
REQUIRE_NOT_FALL_FLYING = builder.comment("Require players to not be gliding with elytra before requesting or accepting a trade.")
.define("requireNotFallFlying", true);
REQUIRE_NOT_RIDING = builder.comment("Require players to not be riding another entity before requesting or accepting a trade.")
.define("requireNotRiding", true);
REQUIRE_SAME_DIMENSION = builder.comment("Require both players to be in the same dimension.")
.define("requireSameDimension", true);
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();
}
private TradeConfig() {}
public static int tradeCommandProximity() {
return TRADE_COMMAND_PROXIMITY.get();
}
public static int requestTimeoutSeconds() {
return REQUEST_TIMEOUT_SECONDS.get();
}
public static int requestCooldownSeconds() {
return REQUEST_COOLDOWN_SECONDS.get();
}
public static boolean enableDebugFeatures() {
return ENABLE_DEBUG_FEATURES.get();
}
public static boolean enableDebugFeaturesSafe() {
try {
return ENABLE_DEBUG_FEATURES.get();
} catch (IllegalStateException ignored) {
return false;
}
}
public static boolean requireSecondConfirmation() {
return REQUIRE_SECOND_CONFIRMATION.get();
}
public static boolean showTradeModifiedWarnings() {
return SHOW_TRADE_MODIFIED_WARNINGS.get();
}
public static boolean requireOnGround() {
return REQUIRE_ON_GROUND.get();
}
public static boolean requireStationary() {
return REQUIRE_STATIONARY.get();
}
public static double stationarySpeedThreshold() {
return STATIONARY_SPEED_THRESHOLD.get();
}
public static boolean requireNoRecentDamage() {
return REQUIRE_NO_RECENT_DAMAGE.get();
}
public static int noDamageSeconds() {
return NO_DAMAGE_SECONDS.get();
}
public static boolean requireNotOnFire() {
return REQUIRE_NOT_ON_FIRE.get();
}
public static boolean requireNotInLiquid() {
return REQUIRE_NOT_IN_LIQUID.get();
}
public static boolean requireNotSleeping() {
return REQUIRE_NOT_SLEEPING.get();
}
public static boolean requireNotFallFlying() {
return REQUIRE_NOT_FALL_FLYING.get();
}
public static boolean requireNotRiding() {
return REQUIRE_NOT_RIDING.get();
}
public static boolean requireSameDimension() {
return REQUIRE_SAME_DIMENSION.get();
}
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

@@ -1,10 +1,11 @@
package com.trunksbomb.trade.mod;
import com.mojang.logging.LogUtils;
import com.trunksbomb.trade.mod.command.TradeCommand;
import com.trunksbomb.trade.mod.network.TradeNetworking;
import com.trunksbomb.trade.mod.trade.TradeManager;
import com.trunksbomb.trade.command.TradeCommand;
import com.trunksbomb.trade.network.TradeNetworking;
import com.trunksbomb.trade.trade.TradeManager;
import net.neoforged.bus.api.IEventBus;
import net.neoforged.fml.ModContainer;
import net.neoforged.fml.common.Mod;
import net.neoforged.fml.event.lifecycle.FMLCommonSetupEvent;
import net.neoforged.neoforge.common.NeoForge;
@@ -18,11 +19,15 @@ public class TradeMod {
public static final String MODID = "trade";
public static final Logger LOGGER = LogUtils.getLogger();
public TradeMod(IEventBus modEventBus) {
public TradeMod(IEventBus modEventBus, ModContainer modContainer) {
modEventBus.addListener(this::commonSetup);
modEventBus.addListener(this::registerPayloads);
modContainer.registerConfig(net.neoforged.fml.config.ModConfig.Type.SERVER, TradeConfig.SPEC);
NeoForge.EVENT_BUS.addListener(this::registerCommands);
NeoForge.EVENT_BUS.addListener(this::onPlayerLogout);
NeoForge.EVENT_BUS.addListener(this::onItemPickup);
NeoForge.EVENT_BUS.addListener(this::onServerTick);
NeoForge.EVENT_BUS.addListener(this::onLivingDamage);
}
private void commonSetup(FMLCommonSetupEvent event) {
@@ -43,4 +48,21 @@ public class TradeMod {
TradeManager.get(player.server).handleDisconnect(player);
}
}
public void onItemPickup(net.neoforged.neoforge.event.entity.player.ItemEntityPickupEvent.Pre event) {
if (event.getPlayer() instanceof net.minecraft.server.level.ServerPlayer player
&& TradeManager.get(player.server).isTrading(player)) {
event.setCanPickup(net.neoforged.neoforge.common.util.TriState.FALSE);
}
}
public void onServerTick(net.neoforged.neoforge.event.tick.ServerTickEvent.Post event) {
TradeManager.get(event.getServer()).tick(event.getServer());
}
public void onLivingDamage(net.neoforged.neoforge.event.entity.living.LivingDamageEvent.Post event) {
if (event.getEntity() instanceof net.minecraft.server.level.ServerPlayer player) {
TradeManager.get(player.server).recordDamage(player);
}
}
}

View File

@@ -1,7 +1,7 @@
package com.trunksbomb.trade.mod.network;
package com.trunksbomb.trade.network;
import com.trunksbomb.trade.mod.TradeMod;
import com.trunksbomb.trade.mod.trade.DebugControlAction;
import com.trunksbomb.trade.trade.DebugControlAction;
import java.util.UUID;
import net.minecraft.network.RegistryFriendlyByteBuf;
import net.minecraft.network.codec.StreamCodec;

View File

@@ -1,14 +1,14 @@
package com.trunksbomb.trade.mod.network;
package com.trunksbomb.trade.network;
import com.trunksbomb.trade.mod.TradeMod;
import com.trunksbomb.trade.mod.trade.TradeAction;
import com.trunksbomb.trade.trade.TradeAction;
import java.util.UUID;
import net.minecraft.network.RegistryFriendlyByteBuf;
import net.minecraft.network.codec.StreamCodec;
import net.minecraft.network.protocol.common.custom.CustomPacketPayload;
import net.minecraft.resources.ResourceLocation;
public record TradeActionPayload(UUID sessionId, TradeAction action, int slot, boolean secondary) implements CustomPacketPayload {
public record TradeActionPayload(UUID sessionId, TradeAction action, int slot, int amount) implements CustomPacketPayload {
public static final Type<TradeActionPayload> TYPE = new Type<>(ResourceLocation.fromNamespaceAndPath(TradeMod.MODID, "trade_action"));
public static final StreamCodec<RegistryFriendlyByteBuf, TradeActionPayload> STREAM_CODEC = new StreamCodec<>() {
@Override
@@ -16,12 +16,12 @@ public record TradeActionPayload(UUID sessionId, TradeAction action, int slot, b
buf.writeUUID(value.sessionId);
buf.writeEnum(value.action);
buf.writeVarInt(value.slot);
buf.writeBoolean(value.secondary);
buf.writeVarInt(value.amount);
}
@Override
public TradeActionPayload decode(RegistryFriendlyByteBuf buf) {
return new TradeActionPayload(buf.readUUID(), buf.readEnum(TradeAction.class), buf.readVarInt(), buf.readBoolean());
return new TradeActionPayload(buf.readUUID(), buf.readEnum(TradeAction.class), buf.readVarInt(), buf.readVarInt());
}
};

View File

@@ -1,4 +1,4 @@
package com.trunksbomb.trade.mod.network;
package com.trunksbomb.trade.network;
import com.trunksbomb.trade.mod.TradeMod;
import java.util.UUID;

View File

@@ -1,8 +1,6 @@
package com.trunksbomb.trade.mod.network;
package com.trunksbomb.trade.network;
import com.trunksbomb.trade.mod.client.TradeScreen;
import com.trunksbomb.trade.mod.trade.TradeManager;
import net.minecraft.client.Minecraft;
import com.trunksbomb.trade.trade.TradeManager;
import net.minecraft.network.chat.Component;
import net.minecraft.server.level.ServerPlayer;
import net.neoforged.neoforge.network.handling.DirectionalPayloadHandler;
@@ -25,6 +23,10 @@ public final class TradeNetworking {
TradeActionPayload.TYPE,
TradeActionPayload.STREAM_CODEC,
TradeNetworking::handleTradeActionServer);
registrar.playToServer(
TradeRequestPayload.TYPE,
TradeRequestPayload.STREAM_CODEC,
TradeNetworking::handleTradeRequestServer);
registrar.playToServer(
DebugTradeControlPayload.TYPE,
DebugTradeControlPayload.STREAM_CODEC,
@@ -32,39 +34,32 @@ public final class TradeNetworking {
}
private static void handleTradeStateClient(TradeStatePayload payload, IPayloadContext context) {
Minecraft minecraft = Minecraft.getInstance();
if (minecraft.player == null) {
return;
}
if (minecraft.screen instanceof TradeScreen screen && screen.sessionId().equals(payload.view().sessionId())) {
screen.updateView(payload.view());
return;
}
minecraft.setScreen(new TradeScreen(
new TradeScreen.TradeMenu(0, minecraft.player.getInventory(), payload.view()),
minecraft.player.getInventory(),
Component.literal("Trade")));
invokeClientState("showOrUpdate", new Class<?>[] {payload.view().getClass()}, payload.view());
}
private static void handleTradeCloseClient(TradeClosePayload payload, IPayloadContext context) {
Minecraft minecraft = Minecraft.getInstance();
if (minecraft.screen instanceof TradeScreen screen && screen.sessionId().equals(payload.sessionId())) {
minecraft.setScreen(null);
}
if (minecraft.player != null) {
minecraft.player.displayClientMessage(payload.reason(), false);
}
invokeClientState("closeTrade", new Class<?>[] {java.util.UUID.class, Component.class}, payload.sessionId(), payload.reason());
}
private static void handleTradeActionServer(TradeActionPayload payload, IPayloadContext context) {
if (context.player() instanceof ServerPlayer player) {
TradeManager.get(player.server).handleAction(player, payload.sessionId(), payload.action(), payload.slot(), payload.secondary());
TradeManager.get(player.server).handleAction(player, payload.sessionId(), payload.action(), payload.slot(), payload.amount());
}
}
private static void handleTradeRequestServer(TradeRequestPayload payload, IPayloadContext context) {
if (!(context.player() instanceof ServerPlayer player)) {
return;
}
if (!(player.serverLevel().getEntity(payload.targetEntityId()) instanceof ServerPlayer target)) {
player.sendSystemMessage(Component.literal("You must be looking at a player to trade."));
return;
}
TradeManager.get(player.server).requestTrade(player, target);
}
private static void handleDebugTradeControlServer(DebugTradeControlPayload payload, IPayloadContext context) {
if (context.player() instanceof ServerPlayer player) {
TradeManager.get(player.server).handleDebugControl(player, payload.sessionId(), payload.action(), payload.spec());
@@ -74,4 +69,13 @@ public final class TradeNetworking {
private static void ignoreTradeStateServer(TradeStatePayload payload, IPayloadContext context) {}
private static void ignoreTradeCloseServer(TradeClosePayload payload, IPayloadContext context) {}
private static void invokeClientState(String methodName, Class<?>[] parameterTypes, Object... arguments) {
try {
Class<?> clientState = Class.forName("com.trunksbomb.trade.client.TradeClientState");
clientState.getMethod(methodName, parameterTypes).invoke(null, arguments);
} catch (ReflectiveOperationException exception) {
throw new RuntimeException("Failed to invoke TradeClientState#" + methodName, exception);
}
}
}

View File

@@ -0,0 +1,27 @@
package com.trunksbomb.trade.network;
import com.trunksbomb.trade.mod.TradeMod;
import net.minecraft.network.RegistryFriendlyByteBuf;
import net.minecraft.network.codec.StreamCodec;
import net.minecraft.network.protocol.common.custom.CustomPacketPayload;
import net.minecraft.resources.ResourceLocation;
public record TradeRequestPayload(int targetEntityId) implements CustomPacketPayload {
public static final Type<TradeRequestPayload> TYPE = new Type<>(ResourceLocation.fromNamespaceAndPath(TradeMod.MODID, "trade_request"));
public static final StreamCodec<RegistryFriendlyByteBuf, TradeRequestPayload> STREAM_CODEC = new StreamCodec<>() {
@Override
public void encode(RegistryFriendlyByteBuf buf, TradeRequestPayload value) {
buf.writeVarInt(value.targetEntityId());
}
@Override
public TradeRequestPayload decode(RegistryFriendlyByteBuf buf) {
return new TradeRequestPayload(buf.readVarInt());
}
};
@Override
public Type<? extends CustomPacketPayload> type() {
return TYPE;
}
}

View File

@@ -1,7 +1,7 @@
package com.trunksbomb.trade.mod.network;
package com.trunksbomb.trade.network;
import com.trunksbomb.trade.mod.TradeMod;
import com.trunksbomb.trade.mod.trade.TradeView;
import com.trunksbomb.trade.trade.TradeView;
import net.minecraft.network.RegistryFriendlyByteBuf;
import net.minecraft.network.codec.StreamCodec;
import net.minecraft.network.protocol.common.custom.CustomPacketPayload;

View File

@@ -1,10 +1,12 @@
package com.trunksbomb.trade.mod.trade;
package com.trunksbomb.trade.trade;
public enum DebugControlAction {
SET_OFFER,
APPEND_RANDOM,
REMOVE_OFFER,
REMOVE_LAST,
SET_UNSAFE,
CLEAR_UNSAFE,
ACCEPT,
CANCEL,
CLOSE

View File

@@ -1,8 +1,10 @@
package com.trunksbomb.trade.mod.trade;
package com.trunksbomb.trade.trade;
import com.trunksbomb.trade.mod.network.TradeClosePayload;
import com.trunksbomb.trade.mod.network.TradeStatePayload;
import com.trunksbomb.trade.network.TradeClosePayload;
import com.trunksbomb.trade.network.TradeStatePayload;
import com.trunksbomb.trade.mod.TradeConfig;
import java.util.ArrayList;
import java.util.EnumSet;
import java.util.List;
import java.util.UUID;
import net.minecraft.core.registries.BuiltInRegistries;
@@ -22,14 +24,19 @@ public class DebugTradeSession {
private final UUID id = UUID.randomUUID();
private final ServerPlayer player;
private final List<ItemStack> selfOffer = blankOffer();
private final List<ItemStack> inventorySnapshot;
private final List<TradeEntry> selfOffer = blankOffer();
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 boolean selfAccepted;
private boolean otherAccepted;
private TradeStage stage = TradeStage.OFFERING;
public DebugTradeSession(ServerPlayer player) {
this.player = player;
this.inventorySnapshot = inventorySnapshot(player);
}
public UUID id() {
@@ -40,6 +47,22 @@ public class DebugTradeSession {
return player;
}
public List<ItemStack> selfOfferSnapshot() {
List<ItemStack> result = new ArrayList<>(OFFER_SLOT_COUNT);
for (TradeEntry entry : selfOffer) {
result.add(entry == null ? ItemStack.EMPTY : entry.stack().copy());
}
return result;
}
public List<ItemStack> otherOfferSnapshot() {
List<ItemStack> result = new ArrayList<>(OFFER_SLOT_COUNT);
for (ItemStack stack : otherOffer) {
result.add(stack.copy());
}
return result;
}
public void sync() {
PacketDistributor.sendToPlayer(player, new TradeStatePayload(view()));
}
@@ -48,62 +71,63 @@ public class DebugTradeSession {
PacketDistributor.sendToPlayer(player, new TradeClosePayload(id, reason));
}
public boolean addFromInventory(int inventorySlot, boolean singleItem) {
public boolean addFromInventory(int inventorySlot, int amount) {
if (stage != TradeStage.OFFERING || inventorySlot < 0 || inventorySlot >= INVENTORY_SLOT_COUNT) {
return false;
}
Inventory inventory = player.getInventory();
ItemStack sourceStack = inventory.getItem(inventorySlot);
ItemStack sourceStack = inventorySnapshot.get(inventorySlot);
if (sourceStack.isEmpty()) {
return false;
}
ItemStack moving = sourceStack.copyWithCount(singleItem ? 1 : sourceStack.getCount());
if (!insertStack(selfOffer, moving)) {
int available = sourceStack.getCount() - reservedCount(inventorySlot);
if (available <= 0) {
return false;
}
sourceStack.shrink(singleItem ? 1 : sourceStack.getCount());
if (sourceStack.isEmpty()) {
inventory.setItem(inventorySlot, ItemStack.EMPTY);
int moveAmount = Math.max(1, Math.min(amount, available));
for (int i = 0; i < selfOffer.size(); i++) {
TradeEntry entry = selfOffer.get(i);
if (entry != null && entry.sourceSlot() == inventorySlot && ItemStack.isSameItemSameComponents(entry.stack(), sourceStack)) {
ItemStack merged = entry.stack().copy();
merged.grow(moveAmount);
selfOffer.set(i, new TradeEntry(inventorySlot, merged));
clearAccepts(false, false, -1);
return true;
}
}
inventory.setChanged();
clearAccepts();
int freeSlot = firstFreeOfferSlot(selfOffer);
if (freeSlot == -1) {
return false;
}
selfOffer.set(freeSlot, new TradeEntry(inventorySlot, sourceStack.copyWithCount(moveAmount)));
clearAccepts(false, false, -1);
return true;
}
public boolean removeFromOffer(int offerSlot, boolean singleItem) {
public boolean removeFromOffer(int offerSlot, int amount) {
if (stage != TradeStage.OFFERING || offerSlot < 0 || offerSlot >= OFFER_SLOT_COUNT) {
return false;
}
ItemStack offered = selfOffer.get(offerSlot);
if (offered.isEmpty()) {
TradeEntry entry = selfOffer.get(offerSlot);
if (entry == null) {
return false;
}
List<ItemStack> workingInventory = inventorySnapshot();
ItemStack returned = offered.copyWithCount(singleItem ? 1 : offered.getCount());
if (!insertStack(workingInventory, returned)) {
return false;
}
Inventory inventory = player.getInventory();
for (int i = 0; i < workingInventory.size(); i++) {
inventory.setItem(i, workingInventory.get(i));
}
inventory.setChanged();
if (singleItem && offered.getCount() > 1) {
ItemStack updated = offered.copy();
updated.shrink(1);
selfOffer.set(offerSlot, updated);
int moveAmount = Math.max(1, Math.min(amount, entry.stack().getCount()));
if (moveAmount < entry.stack().getCount()) {
ItemStack updated = entry.stack().copy();
updated.shrink(moveAmount);
selfOffer.set(offerSlot, new TradeEntry(entry.sourceSlot(), updated));
} else {
selfOffer.set(offerSlot, ItemStack.EMPTY);
selfOffer.set(offerSlot, null);
}
clearAccepts();
clearAccepts(true, false, offerSlot);
return true;
}
@@ -119,7 +143,7 @@ public class DebugTradeSession {
for (int i = 0; i < OFFER_SLOT_COUNT; i++) {
otherOffer.set(i, i < offer.size() ? offer.get(i).copy() : ItemStack.EMPTY);
}
clearAccepts();
clearAccepts(false, false, -1);
}
public boolean appendOtherOffer(List<ItemStack> offer) {
@@ -135,18 +159,25 @@ public class DebugTradeSession {
}
if (changed) {
clearAccepts();
clearAccepts(false, false, -1);
}
return changed;
}
public boolean removeOtherOffer(String spec) {
if ("all".equalsIgnoreCase(spec)) {
boolean changed = false;
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);
}
clearAccepts();
return true;
if (changed) {
clearAccepts(false, false, -1);
}
return changed;
}
int split = spec.indexOf(':');
@@ -173,7 +204,7 @@ public class DebugTradeSession {
otherOffer.set(slot, updated);
}
clearAccepts();
clearAccepts(false, true, slot);
return true;
}
@@ -183,12 +214,28 @@ public class DebugTradeSession {
continue;
}
otherOffer.set(i, ItemStack.EMPTY);
clearAccepts();
clearAccepts(false, true, i);
return true;
}
return false;
}
public void setUnsafeState(DebugUnsafeState state, boolean unsafe) {
if (unsafe) {
unsafeStates.add(state);
} else {
unsafeStates.remove(state);
}
}
public void clearUnsafeStates() {
unsafeStates.clear();
}
public boolean hasUnsafeState(DebugUnsafeState state) {
return unsafeStates.contains(state);
}
public void advanceToConfirmation() {
if (stage == TradeStage.OFFERING && selfAccepted && otherAccepted) {
stage = TradeStage.CONFIRMING;
@@ -206,7 +253,11 @@ public class DebugTradeSession {
}
public boolean completeTrade() {
List<ItemStack> result = inventorySnapshot();
List<ItemStack> result = inventorySnapshot(player);
if (!removeOutgoing(result, selfOffer)) {
return false;
}
for (ItemStack stack : otherOffer) {
if (!stack.isEmpty() && !insertStack(result, stack.copy())) {
return false;
@@ -222,6 +273,7 @@ public class DebugTradeSession {
}
public TradeView view() {
boolean showModifiedWarnings = TradeConfig.showTradeModifiedWarnings();
return new TradeView(
id,
player.getGameProfile().getName(),
@@ -230,10 +282,16 @@ public class DebugTradeSession {
stage,
selfAccepted,
otherAccepted,
inventorySnapshot(),
showModifiedWarnings && hasChangedSlots(),
inventoryDisplay(),
emptyReservedSnapshot(),
blankInventoryBlockedSlots(),
selfOfferSnapshot(),
otherOfferSnapshot());
otherOfferSnapshot(),
blankChangedSlots(),
blankChangedSlots(),
showModifiedWarnings ? changedSlotSnapshot(selfChangedSlots) : blankChangedSlots(),
showModifiedWarnings ? changedSlotSnapshot(otherChangedSlots) : blankChangedSlots());
}
public static List<ItemStack> parseOfferSpec(String spec) {
@@ -310,6 +368,25 @@ public class DebugTradeSession {
}
}
private static boolean removeOutgoing(List<ItemStack> working, List<TradeEntry> outgoing) {
for (TradeEntry entry : outgoing) {
if (entry == null) {
continue;
}
ItemStack current = working.get(entry.sourceSlot());
if (!ItemStack.isSameItemSameComponents(current, entry.stack()) || current.getCount() < entry.stack().getCount()) {
return false;
}
ItemStack updated = current.copy();
updated.shrink(entry.stack().getCount());
working.set(entry.sourceSlot(), updated);
}
return true;
}
private static boolean insertStack(List<ItemStack> working, ItemStack incoming) {
if (incoming.isEmpty()) {
return true;
@@ -348,6 +425,66 @@ public class DebugTradeSession {
return false;
}
private int reservedCount(int inventorySlot) {
int count = 0;
for (TradeEntry entry : selfOffer) {
if (entry != null && entry.sourceSlot() == inventorySlot) {
count += entry.stack().getCount();
}
}
return count;
}
private void clearAccepts(boolean markSelfChanged, boolean markOtherChanged, int changedSlot) {
selfAccepted = 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() {
List<ItemStack> result = new ArrayList<>(INVENTORY_SLOT_COUNT);
for (ItemStack stack : inventorySnapshot) {
result.add(stack.copy());
}
return result;
}
private List<ItemStack> inventoryDisplay() {
List<ItemStack> result = inventorySnapshotCopy();
for (int i = 0; i < INVENTORY_SLOT_COUNT; i++) {
int reserved = reservedCount(i);
if (reserved <= 0) {
continue;
}
ItemStack stack = result.get(i);
if (stack.isEmpty()) {
continue;
}
ItemStack updated = stack.copy();
updated.shrink(reserved);
result.set(i, updated);
}
return result;
}
private static List<Integer> emptyReservedSnapshot() {
List<Integer> result = new ArrayList<>(INVENTORY_SLOT_COUNT);
for (int i = 0; i < INVENTORY_SLOT_COUNT; i++) {
@@ -356,12 +493,27 @@ public class DebugTradeSession {
return result;
}
private void clearAccepts() {
selfAccepted = false;
otherAccepted = false;
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 List<ItemStack> inventorySnapshot() {
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) {
List<ItemStack> result = new ArrayList<>(INVENTORY_SLOT_COUNT);
Inventory inventory = player.getInventory();
for (int i = 0; i < INVENTORY_SLOT_COUNT; i++) {
@@ -370,26 +522,10 @@ public class DebugTradeSession {
return result;
}
private List<ItemStack> selfOfferSnapshot() {
List<ItemStack> result = new ArrayList<>(OFFER_SLOT_COUNT);
for (ItemStack stack : selfOffer) {
result.add(stack.copy());
}
return result;
}
private List<ItemStack> otherOfferSnapshot() {
List<ItemStack> result = new ArrayList<>(OFFER_SLOT_COUNT);
for (ItemStack stack : otherOffer) {
result.add(stack.copy());
}
return result;
}
private static List<ItemStack> blankOffer() {
List<ItemStack> result = new ArrayList<>(OFFER_SLOT_COUNT);
private static List<TradeEntry> blankOffer() {
List<TradeEntry> result = new ArrayList<>(OFFER_SLOT_COUNT);
for (int i = 0; i < OFFER_SLOT_COUNT; i++) {
result.add(ItemStack.EMPTY);
result.add(null);
}
return result;
}
@@ -402,4 +538,12 @@ public class DebugTradeSession {
return result;
}
private static int firstFreeOfferSlot(List<TradeEntry> offer) {
for (int i = 0; i < offer.size(); i++) {
if (offer.get(i) == null) {
return i;
}
}
return -1;
}
}

View File

@@ -0,0 +1,11 @@
package com.trunksbomb.trade.trade;
public enum DebugUnsafeState {
DAMAGE,
FIRE,
LIQUID,
MOVING,
SLEEPING,
RIDING,
GLIDING
}

View File

@@ -1,4 +1,4 @@
package com.trunksbomb.trade.mod.trade;
package com.trunksbomb.trade.trade;
public enum TradeAction {
ADD_ITEM,

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

@@ -1,4 +1,4 @@
package com.trunksbomb.trade.mod.trade;
package com.trunksbomb.trade.trade;
import net.minecraft.world.item.ItemStack;

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,114 @@
package com.trunksbomb.trade.trade;
import com.mojang.datafixers.util.Pair;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.UUID;
import net.minecraft.core.HolderLookup;
import net.minecraft.nbt.CompoundTag;
import net.minecraft.nbt.ListTag;
import net.minecraft.nbt.StringTag;
import net.minecraft.nbt.Tag;
import net.minecraft.server.MinecraftServer;
import net.minecraft.world.level.saveddata.SavedData;
public class TradePreferencesData extends SavedData {
private static final String DATA_NAME = "trade_preferences";
private final Map<UUID, Boolean> tradeEnabled = new HashMap<>();
private final Map<UUID, Set<UUID>> ignoredPlayers = new HashMap<>();
public static TradePreferencesData get(MinecraftServer server) {
return server.overworld().getDataStorage().computeIfAbsent(factory(), DATA_NAME);
}
private static Factory<TradePreferencesData> factory() {
return new Factory<>(TradePreferencesData::new, TradePreferencesData::load);
}
private static TradePreferencesData load(CompoundTag tag, HolderLookup.Provider registries) {
TradePreferencesData data = new TradePreferencesData();
CompoundTag enabledTag = tag.getCompound("trade_enabled");
for (String key : enabledTag.getAllKeys()) {
data.tradeEnabled.put(UUID.fromString(key), enabledTag.getBoolean(key));
}
CompoundTag ignoredTag = tag.getCompound("ignored_players");
for (String key : ignoredTag.getAllKeys()) {
ListTag values = ignoredTag.getList(key, Tag.TAG_STRING);
Set<UUID> ignored = new HashSet<>();
for (Tag value : values) {
ignored.add(UUID.fromString(value.getAsString()));
}
data.ignoredPlayers.put(UUID.fromString(key), ignored);
}
return data;
}
public boolean isTradeEnabled(UUID playerId) {
return tradeEnabled.getOrDefault(playerId, true);
}
public void setTradeEnabled(UUID playerId, boolean enabled) {
tradeEnabled.put(playerId, enabled);
setDirty();
}
public boolean isIgnoring(UUID playerId, UUID ignoredPlayerId) {
return ignoredPlayers.getOrDefault(playerId, Set.of()).contains(ignoredPlayerId);
}
public boolean addIgnoredPlayer(UUID playerId, UUID ignoredPlayerId) {
boolean changed = ignoredPlayers.computeIfAbsent(playerId, ignored -> new HashSet<>()).add(ignoredPlayerId);
if (changed) {
setDirty();
}
return changed;
}
public boolean removeIgnoredPlayer(UUID playerId, UUID ignoredPlayerId) {
Set<UUID> ignored = ignoredPlayers.get(playerId);
if (ignored == null) {
return false;
}
boolean changed = ignored.remove(ignoredPlayerId);
if (ignored.isEmpty()) {
ignoredPlayers.remove(playerId);
}
if (changed) {
setDirty();
}
return changed;
}
public List<UUID> ignoredPlayers(UUID playerId) {
return new ArrayList<>(ignoredPlayers.getOrDefault(playerId, Set.of()));
}
@Override
public CompoundTag save(CompoundTag tag, HolderLookup.Provider registries) {
CompoundTag enabledTag = new CompoundTag();
for (Map.Entry<UUID, Boolean> entry : tradeEnabled.entrySet()) {
enabledTag.putBoolean(entry.getKey().toString(), entry.getValue());
}
tag.put("trade_enabled", enabledTag);
CompoundTag ignoredTag = new CompoundTag();
for (Map.Entry<UUID, Set<UUID>> entry : ignoredPlayers.entrySet()) {
ListTag list = new ListTag();
for (UUID ignored : entry.getValue()) {
list.add(StringTag.valueOf(ignored.toString()));
}
ignoredTag.put(entry.getKey().toString(), list);
}
tag.put("ignored_players", ignoredTag);
return tag;
}
}

View File

@@ -1,7 +1,8 @@
package com.trunksbomb.trade.mod.trade;
package com.trunksbomb.trade.trade;
import com.trunksbomb.trade.mod.network.TradeClosePayload;
import com.trunksbomb.trade.mod.network.TradeStatePayload;
import com.trunksbomb.trade.mod.TradeConfig;
import com.trunksbomb.trade.network.TradeClosePayload;
import com.trunksbomb.trade.network.TradeStatePayload;
import java.util.ArrayList;
import java.util.List;
import java.util.UUID;
@@ -18,8 +19,12 @@ public class TradeSession {
private final UUID id = UUID.randomUUID();
private final ServerPlayer first;
private final ServerPlayer second;
private final List<ItemStack> firstOffer = blankOffer();
private final List<ItemStack> secondOffer = blankOffer();
private final List<ItemStack> firstInventory;
private final List<ItemStack> secondInventory;
private final List<TradeEntry> firstOffer = blankOffer();
private final List<TradeEntry> secondOffer = blankOffer();
private final List<Boolean> firstChangedSlots = blankChangedSlots();
private final List<Boolean> secondChangedSlots = blankChangedSlots();
private boolean firstAccepted;
private boolean secondAccepted;
private TradeStage stage = TradeStage.OFFERING;
@@ -27,6 +32,8 @@ public class TradeSession {
public TradeSession(ServerPlayer first, ServerPlayer second) {
this.first = first;
this.second = second;
this.firstInventory = inventorySnapshot(first);
this.secondInventory = inventorySnapshot(second);
}
public UUID id() {
@@ -41,6 +48,14 @@ public class TradeSession {
return second;
}
public List<ItemStack> firstOfferSnapshot() {
return offerSnapshot(firstOffer);
}
public List<ItemStack> secondOfferSnapshot() {
return offerSnapshot(secondOffer);
}
public boolean involves(ServerPlayer player) {
return player == first || player == second;
}
@@ -59,59 +74,72 @@ public class TradeSession {
PacketDistributor.sendToPlayer(second, new TradeClosePayload(id, reason));
}
public boolean addFromInventory(ServerPlayer player, int inventorySlot, boolean singleItem) {
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;
}
List<ItemStack> offer = offerFor(player);
Inventory inventory = player.getInventory();
ItemStack sourceStack = inventory.getItem(inventorySlot);
ItemStack sourceStack = inventoryFor(player).get(inventorySlot);
if (sourceStack.isEmpty()) {
return false;
}
ItemStack moving = sourceStack.copyWithCount(singleItem ? 1 : sourceStack.getCount());
if (!insertStack(offer, moving)) {
int available = sourceStack.getCount() - reservedCount(player, inventorySlot);
if (available <= 0) {
return false;
}
sourceStack.shrink(singleItem ? 1 : sourceStack.getCount());
if (sourceStack.isEmpty()) {
inventory.setItem(inventorySlot, ItemStack.EMPTY);
int moveAmount = Math.max(1, Math.min(amount, available));
List<TradeEntry> offer = offerFor(player);
for (int i = 0; i < offer.size(); i++) {
TradeEntry entry = offer.get(i);
if (entry != null && entry.sourceSlot() == inventorySlot && ItemStack.isSameItemSameComponents(entry.stack(), sourceStack)) {
ItemStack merged = entry.stack().copy();
merged.grow(moveAmount);
offer.set(i, new TradeEntry(inventorySlot, merged));
clearAccepts(false, false, -1);
return true;
}
}
inventory.setChanged();
clearAccepts();
int freeSlot = firstFreeOfferSlot(offer);
if (freeSlot == -1) {
return false;
}
offer.set(freeSlot, new TradeEntry(inventorySlot, sourceStack.copyWithCount(moveAmount)));
clearAccepts(false, false, -1);
return true;
}
public boolean removeFromOffer(ServerPlayer player, int offerSlot, boolean singleItem) {
public boolean removeFromOffer(ServerPlayer player, int offerSlot, int amount) {
if (stage != TradeStage.OFFERING || offerSlot < 0 || offerSlot >= OFFER_SLOT_COUNT) {
return false;
}
List<ItemStack> offer = offerFor(player);
ItemStack offered = offer.get(offerSlot);
if (offered.isEmpty()) {
List<TradeEntry> offer = offerFor(player);
TradeEntry entry = offer.get(offerSlot);
if (entry == null) {
return false;
}
Inventory inventory = player.getInventory();
List<ItemStack> workingInventory = inventorySnapshot(player);
ItemStack returned = offered.copyWithCount(singleItem ? 1 : offered.getCount());
if (!insertStack(workingInventory, returned)) {
return false;
}
applyInventory(player, workingInventory);
if (singleItem && offered.getCount() > 1) {
ItemStack updated = offered.copy();
updated.shrink(1);
offer.set(offerSlot, updated);
int moveAmount = Math.max(1, Math.min(amount, entry.stack().getCount()));
if (moveAmount < entry.stack().getCount()) {
ItemStack updated = entry.stack().copy();
updated.shrink(moveAmount);
offer.set(offerSlot, new TradeEntry(entry.sourceSlot(), updated));
} else {
offer.set(offerSlot, ItemStack.EMPTY);
offer.set(offerSlot, null);
}
clearAccepts();
clearAccepts(player == first, player == second, offerSlot);
return true;
}
@@ -139,23 +167,42 @@ public class TradeSession {
return stage == TradeStage.CONFIRMING;
}
public boolean completeTrade() {
List<ItemStack> firstResult = simulateResult(first, secondOffer);
List<ItemStack> secondResult = simulateResult(second, firstOffer);
public CompletionResult completeTrade() {
if (first.level() != second.level()) {
return CompletionResult.failure(Component.literal("Trade cancelled because both players must remain in the same dimension."));
}
int maxDistance = TradeConfig.tradeCommandProximity();
if (maxDistance > 0) {
double maxDistanceSquared = maxDistance * maxDistance;
if (first.distanceToSqr(second) > maxDistanceSquared) {
return CompletionResult.failure(Component.literal("Trade cancelled because players moved too far apart before it was finalized."));
}
}
if (!inventoryMatchesSnapshot(first, firstInventory)) {
return CompletionResult.failure(Component.literal("Trade cancelled because " + first.getGameProfile().getName() + "'s inventory changed during the trade."));
}
if (!inventoryMatchesSnapshot(second, secondInventory)) {
return CompletionResult.failure(Component.literal("Trade cancelled because " + second.getGameProfile().getName() + "'s inventory changed during the trade."));
}
List<ItemStack> firstResult = simulateResult(first, firstOffer, secondOffer);
List<ItemStack> secondResult = simulateResult(second, secondOffer, firstOffer);
if (firstResult == null || secondResult == null) {
return false;
return CompletionResult.failure(Component.literal("Trade cancelled because one player could not fit all traded items."));
}
applyInventory(first, firstResult);
applyInventory(second, secondResult);
clearOffer(firstOffer);
clearOffer(secondOffer);
return true;
return CompletionResult.success();
}
public TradeView viewFor(ServerPlayer player) {
boolean isFirst = player == first;
ServerPlayer other = isFirst ? second : first;
boolean showModifiedWarnings = TradeConfig.showTradeModifiedWarnings();
return new TradeView(
id,
player.getGameProfile().getName(),
@@ -164,20 +211,30 @@ public class TradeSession {
stage,
isFirst ? firstAccepted : secondAccepted,
isFirst ? secondAccepted : firstAccepted,
inventorySnapshot(player),
showModifiedWarnings && hasChangedSlots(),
inventoryDisplayFor(player),
emptyReservedSnapshot(),
blankInventoryBlockedSlots(),
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) {
PacketDistributor.sendToPlayer(player, new TradeStatePayload(viewFor(player)));
}
private List<ItemStack> simulateResult(ServerPlayer player, List<ItemStack> incoming) {
private List<ItemStack> simulateResult(ServerPlayer player, List<TradeEntry> outgoing, List<TradeEntry> incoming) {
List<ItemStack> working = inventorySnapshot(player);
for (ItemStack stack : incoming) {
if (!stack.isEmpty() && !insertStack(working, stack.copy())) {
if (!removeOutgoing(working, outgoing)) {
return null;
}
for (TradeEntry entry : incoming) {
if (entry != null && !insertStack(working, entry.stack().copy())) {
return null;
}
}
@@ -185,6 +242,25 @@ public class TradeSession {
return working;
}
private static boolean removeOutgoing(List<ItemStack> working, List<TradeEntry> outgoing) {
for (TradeEntry entry : outgoing) {
if (entry == null) {
continue;
}
ItemStack current = working.get(entry.sourceSlot());
if (!ItemStack.isSameItemSameComponents(current, entry.stack()) || current.getCount() < entry.stack().getCount()) {
return false;
}
ItemStack updated = current.copy();
updated.shrink(entry.stack().getCount());
working.set(entry.sourceSlot(), updated);
}
return true;
}
private static boolean insertStack(List<ItemStack> working, ItemStack incoming) {
if (incoming.isEmpty()) {
return true;
@@ -223,12 +299,6 @@ public class TradeSession {
return false;
}
private static void clearOffer(List<ItemStack> offer) {
for (int i = 0; i < offer.size(); i++) {
offer.set(i, ItemStack.EMPTY);
}
}
private static void applyInventory(ServerPlayer player, List<ItemStack> result) {
Inventory inventory = player.getInventory();
for (int i = 0; i < result.size(); i++) {
@@ -237,17 +307,99 @@ public class TradeSession {
inventory.setChanged();
}
private static List<ItemStack> blankOffer() {
List<ItemStack> result = new ArrayList<>(OFFER_SLOT_COUNT);
private static boolean inventoryMatchesSnapshot(ServerPlayer player, List<ItemStack> snapshot) {
Inventory inventory = player.getInventory();
for (int i = 0; i < INVENTORY_SLOT_COUNT; i++) {
if (!ItemStack.matches(inventory.getItem(i), snapshot.get(i))) {
return false;
}
}
return true;
}
private static List<TradeEntry> blankOffer() {
List<TradeEntry> result = new ArrayList<>(OFFER_SLOT_COUNT);
for (int i = 0; i < OFFER_SLOT_COUNT; i++) {
result.add(ItemStack.EMPTY);
result.add(null);
}
return result;
}
private void clearAccepts() {
private static int firstFreeOfferSlot(List<TradeEntry> offer) {
for (int i = 0; i < offer.size(); i++) {
if (offer.get(i) == null) {
return i;
}
}
return -1;
}
private void clearAccepts(boolean markFirstChanged, boolean markSecondChanged, int changedSlot) {
firstAccepted = 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) {
int count = 0;
for (TradeEntry entry : offerFor(player)) {
if (entry != null && entry.sourceSlot() == inventorySlot) {
count += entry.stack().getCount();
}
}
return count;
}
private List<TradeEntry> offerFor(ServerPlayer player) {
return player == first ? firstOffer : secondOffer;
}
private List<ItemStack> inventoryFor(ServerPlayer player) {
return player == first ? firstInventory : secondInventory;
}
private List<ItemStack> inventorySnapshotFor(ServerPlayer player) {
List<ItemStack> result = new ArrayList<>(INVENTORY_SLOT_COUNT);
for (ItemStack stack : inventoryFor(player)) {
result.add(stack.copy());
}
return result;
}
private List<ItemStack> inventoryDisplayFor(ServerPlayer player) {
List<ItemStack> result = inventorySnapshotFor(player);
for (int i = 0; i < INVENTORY_SLOT_COUNT; i++) {
int reserved = reservedCount(player, i);
if (reserved <= 0) {
continue;
}
ItemStack stack = result.get(i);
if (stack.isEmpty()) {
continue;
}
ItemStack updated = stack.copy();
updated.shrink(reserved);
result.set(i, updated);
}
return result;
}
private static List<Integer> emptyReservedSnapshot() {
@@ -258,8 +410,24 @@ public class TradeSession {
return result;
}
private List<ItemStack> offerFor(ServerPlayer player) {
return player == first ? firstOffer : secondOffer;
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) {
@@ -271,11 +439,21 @@ public class TradeSession {
return result;
}
private static List<ItemStack> offerSnapshot(List<ItemStack> offer) {
private static List<ItemStack> offerSnapshot(List<TradeEntry> offer) {
List<ItemStack> result = new ArrayList<>(OFFER_SLOT_COUNT);
for (ItemStack stack : offer) {
result.add(stack.copy());
for (TradeEntry entry : offer) {
result.add(entry == null ? ItemStack.EMPTY : entry.stack().copy());
}
return result;
}
public record CompletionResult(boolean successful, Component failureReason) {
public static CompletionResult success() {
return new CompletionResult(true, null);
}
public static CompletionResult failure(Component failureReason) {
return new CompletionResult(false, failureReason);
}
}
}

View File

@@ -1,4 +1,4 @@
package com.trunksbomb.trade.mod.trade;
package com.trunksbomb.trade.trade;
public enum TradeStage {
OFFERING,

View File

@@ -1,4 +1,4 @@
package com.trunksbomb.trade.mod.trade;
package com.trunksbomb.trade.trade;
import java.util.ArrayList;
import java.util.List;
@@ -15,10 +15,16 @@ public record TradeView(
TradeStage stage,
boolean selfAccepted,
boolean otherAccepted,
boolean itemsChanged,
List<ItemStack> inventory,
List<Integer> reservedCounts,
List<Boolean> inventoryBlockedSlots,
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 OFFER_SLOT_COUNT = 36;
@@ -33,10 +39,16 @@ public record TradeView(
buf.writeEnum(value.stage);
buf.writeBoolean(value.selfAccepted);
buf.writeBoolean(value.otherAccepted);
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);
}
@Override
@@ -49,10 +61,16 @@ public record TradeView(
buf.readEnum(TradeStage.class),
buf.readBoolean(),
buf.readBoolean(),
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));
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;
}
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;
}
}

View File

@@ -1,4 +1,5 @@
{
"key.trade.request_trade": "Request Trade",
"trade.trade.title": "Trading Screen",
"trade.trade.offer": "Offer screen",
"trade.trade.confirm": "Confirmation screen"