Compare commits

...

8 Commits

Author SHA1 Message Date
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
26 changed files with 2407 additions and 277 deletions

212
README.md
View File

@@ -1,25 +1,199 @@
![screenshot1.png](screenshot1.png)
Installation information # Trade
=======
This template repository can be directly cloned to get you started with a new `Trade` is a NeoForge mod for Minecraft `1.21.1` that adds a secure player-to-player trading interface inspired by Runescape.
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).
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 ## Features
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.
Mapping Names: - Player-to-player trade requests with an accept / decline handshake
============ - Shared trade screen with separate offer areas for both players
By default, the MDK is configured to use the official mapping names from Mojang for methods and fields - Two-step confirmation flow
in the Minecraft codebase. These names are covered by a specific license. All modders should be aware of this - Quantity-based item selection with quick amounts and `Trade X`
license. For the latest license text, refer to the mapping file itself, or the reference copy here: - Inventory-safe finalization: nothing changes until safety and security checks pass and the trade succeeds
https://github.com/NeoForged/NeoForm/blob/main/Mojang.md - Configurable trade safety checks to prevent dangerous mid-combat or mid-movement trading
- Per-player trade toggles and ignore list support
- File-based trade audit log for server operators
Additional Resources: ## Player Usage
==========
Community Documentation: https://docs.neoforged.net/ ### Starting a trade
NeoForged Discord: https://discord.neoforged.net/
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
## Commands
### Player commands
```mcfunction
/trade <player>
/trade yes
/trade no
/trade toggle
/trade on
/trade off
/trade ignore <player>
/trade unignore <player>
/trade ignorelist
```
### 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
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
### Debug
- `enableDebugFeatures`
- default: `false`
- enables debug commands and debug UI/testing tools
### 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`
## 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 java.util.UUID;
import net.minecraft.client.Minecraft; import net.minecraft.client.Minecraft;
import net.minecraft.network.chat.Component; import net.minecraft.network.chat.Component;
@@ -18,6 +18,13 @@ public final class TradeClientState {
return minecraft; 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) { public static void showOrUpdate(Minecraft minecraftInstance, TradeView view) {
minecraft = minecraftInstance; minecraft = minecraftInstance;
if (minecraft.screen instanceof TradeScreen screen && screen.sessionId().equals(view.sessionId())) { 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"))); 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) { public static void closeTrade(Minecraft minecraftInstance, UUID sessionId, Component reason) {
minecraft = minecraftInstance; minecraft = minecraftInstance;
if (minecraft.screen instanceof TradeScreen screen && screen.sessionId().equals(sessionId)) { if (minecraft.screen instanceof TradeScreen screen && screen.sessionId().equals(sessionId)) {
minecraft.setScreen(null); minecraft.setScreen(null);
} }
if (minecraft.player != null) {
minecraft.player.displayClientMessage(reason, false);
}
} }
} }

View File

@@ -1,14 +1,18 @@
package com.trunksbomb.trade.mod.client; package com.trunksbomb.trade.client;
import com.trunksbomb.trade.mod.network.TradeActionPayload; import com.trunksbomb.trade.network.TradeActionPayload;
import com.trunksbomb.trade.mod.network.DebugTradeControlPayload; import com.trunksbomb.trade.network.DebugTradeControlPayload;
import com.trunksbomb.trade.mod.trade.DebugControlAction; import com.trunksbomb.trade.trade.DebugControlAction;
import com.trunksbomb.trade.mod.trade.TradeAction; import com.trunksbomb.trade.trade.DebugUnsafeState;
import com.trunksbomb.trade.mod.trade.TradeStage; import com.trunksbomb.trade.trade.TradeAction;
import com.trunksbomb.trade.mod.trade.TradeView; 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 java.util.UUID;
import net.minecraft.client.gui.GuiGraphics; import net.minecraft.client.gui.GuiGraphics;
import net.minecraft.client.gui.components.Button; import net.minecraft.client.gui.components.Button;
import net.minecraft.client.gui.components.EditBox;
import net.minecraft.client.gui.screens.inventory.AbstractContainerScreen; import net.minecraft.client.gui.screens.inventory.AbstractContainerScreen;
import net.minecraft.network.chat.Component; import net.minecraft.network.chat.Component;
import net.minecraft.resources.ResourceLocation; import net.minecraft.resources.ResourceLocation;
@@ -30,8 +34,11 @@ public class TradeScreen extends AbstractContainerScreen<TradeScreen.TradeMenu>
private static final int OFFER_START_Y = 18; private static final int OFFER_START_Y = 18;
private static final int INVENTORY_START_X = 62; private static final int INVENTORY_START_X = 62;
private static final int INVENTORY_START_Y = 139; 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 CENTER_COLUMN_X = 115;
private static final int STATUS_LABEL_Y = 54; private static final int STATUS_LABEL_Y = 54;
private static final int CONFIRM_LABEL_Y = 34;
private static final int ACCEPT_BUTTON_Y = 74; private static final int ACCEPT_BUTTON_Y = 74;
private static final int CANCEL_BUTTON_Y = 98; private static final int CANCEL_BUTTON_Y = 98;
private static final int ACTION_BUTTON_WIDTH = 48; private static final int ACTION_BUTTON_WIDTH = 48;
@@ -40,10 +47,13 @@ public class TradeScreen extends AbstractContainerScreen<TradeScreen.TradeMenu>
private static final int DEBUG_BUTTON_WIDTH = 86; private static final int DEBUG_BUTTON_WIDTH = 86;
private static final int DEBUG_BUTTON_HEIGHT = 20; private static final int DEBUG_BUTTON_HEIGHT = 20;
private static final int DEBUG_BUTTON_GAP = 4; private static final int DEBUG_BUTTON_GAP = 4;
private static final int CONFIRM_PANEL_X = 89; private static final int DEBUG_SMALL_BUTTON_WIDTH = 41;
private static final int CONFIRM_PANEL_Y = 77; private static final int AMOUNT_PROMPT_WIDTH = 90;
private static final int CONFIRM_PANEL_WIDTH = 106; private static final int AMOUNT_PROMPT_HEIGHT = 44;
private static final int CONFIRM_PANEL_HEIGHT = 64; private ContextMenu contextMenu;
private EditBox amountInput;
private AmountPrompt amountPrompt;
private Button acceptButton;
public TradeScreen(TradeMenu menu, Inventory inventory, Component title) { public TradeScreen(TradeMenu menu, Inventory inventory, Component title) {
super(menu, inventory, title); super(menu, inventory, title);
@@ -64,13 +74,19 @@ public class TradeScreen extends AbstractContainerScreen<TradeScreen.TradeMenu>
@Override @Override
protected void init() { protected void init() {
super.init(); super.init();
titleLabelX = LEFT_OFFER_START_X; titleLabelX = BANNER_X;
titleLabelY = 6; 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) .bounds(leftPos + CENTER_COLUMN_X + 3, topPos + ACCEPT_BUTTON_Y, ACTION_BUTTON_WIDTH, ACTION_BUTTON_HEIGHT)
.build()); .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) .bounds(leftPos + CENTER_COLUMN_X + 3, topPos + CANCEL_BUTTON_Y, ACTION_BUTTON_WIDTH, ACTION_BUTTON_HEIGHT)
.build()); .build());
@@ -89,21 +105,49 @@ public class TradeScreen extends AbstractContainerScreen<TradeScreen.TradeMenu>
addRenderableWidget(Button.builder(Component.literal("Remove Item"), button -> sendDebug(DebugControlAction.REMOVE_LAST)) 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) .bounds(debugX, debugY + 3 * (DEBUG_BUTTON_HEIGHT + DEBUG_BUTTON_GAP), DEBUG_BUTTON_WIDTH, DEBUG_BUTTON_HEIGHT)
.build()); .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 @Override
public boolean keyPressed(int keyCode, int scanCode, int modifiers) { 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)) { if (minecraft != null && minecraft.options.keyInventory.matches(keyCode, scanCode)) {
onClose(); onClose();
return true; return true;
} }
if (hoveredSlot != null) {
return true;
}
return super.keyPressed(keyCode, scanCode, modifiers); return super.keyPressed(keyCode, scanCode, modifiers);
} }
@Override @Override
public void onClose() { public void onClose() {
sendAction(TradeAction.CLOSE, -1, false); sendAction(TradeAction.CLOSE, -1, 1);
if (minecraft != null) { if (minecraft != null) {
minecraft.setScreen(null); minecraft.setScreen(null);
} }
@@ -111,12 +155,37 @@ public class TradeScreen extends AbstractContainerScreen<TradeScreen.TradeMenu>
@Override @Override
public boolean mouseClicked(double mouseX, double mouseY, int button) { 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 (menu.view().stage() == TradeStage.CONFIRMING) {
if (hoveredSlot != null) {
return true;
}
return super.mouseClicked(mouseX, mouseY, button); return super.mouseClicked(mouseX, mouseY, button);
} }
if (hoveredSlot instanceof SelfOfferSlot selfOfferSlot) { 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; return true;
} }
@@ -124,8 +193,16 @@ public class TradeScreen extends AbstractContainerScreen<TradeScreen.TradeMenu>
return true; return true;
} }
if (hoveredSlot != null && hoveredSlot.container == menu.playerInventory()) { if (hoveredSlot instanceof GhostInventorySlot ghostInventorySlot) {
sendAction(TradeAction.ADD_ITEM, hoveredSlot.getSlotIndex(), button == 1); if (button == 1) {
openContextMenu(mouseX, mouseY, TradeAction.ADD_ITEM, ghostInventorySlot.inventoryIndex(), hoveredSlot.getItem(), "Trade");
} else {
sendAction(TradeAction.ADD_ITEM, ghostInventorySlot.inventoryIndex(), 1);
}
return true;
}
if (hoveredSlot != null) {
return true; return true;
} }
@@ -135,55 +212,70 @@ public class TradeScreen extends AbstractContainerScreen<TradeScreen.TradeMenu>
@Override @Override
protected void renderBg(GuiGraphics guiGraphics, float partialTick, int mouseX, int mouseY) { protected void renderBg(GuiGraphics guiGraphics, float partialTick, int mouseX, int mouseY) {
guiGraphics.blit(TEXTURE, leftPos, topPos, 0, 0, imageWidth, imageHeight, imageWidth, imageHeight); guiGraphics.blit(TEXTURE, leftPos, topPos, 0, 0, imageWidth, imageHeight, imageWidth, imageHeight);
if (menu.view().stage() == TradeStage.CONFIRMING) {
renderConfirmationOverlay(guiGraphics);
}
} }
@Override @Override
protected void renderLabels(GuiGraphics guiGraphics, int mouseX, int mouseY) { protected void renderLabels(GuiGraphics guiGraphics, int mouseX, int mouseY) {
guiGraphics.drawString(font, menu.view().selfName(), titleLabelX, titleLabelY, 0x404040, false); guiGraphics.drawString(font, "Trading with " + menu.view().otherName(), titleLabelX, titleLabelY, 0x404040, false);
guiGraphics.drawString(font, menu.view().otherName(), RIGHT_OFFER_START_X, 6, 0x404040, false);
guiGraphics.drawString(font, playerInventoryTitle, inventoryLabelX, inventoryLabelY, 0x404040, false); guiGraphics.drawString(font, playerInventoryTitle, inventoryLabelX, inventoryLabelY, 0x404040, false);
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()) { if (menu.view().otherAccepted()) {
drawScaledCenteredColumnText(guiGraphics, "Other player", STATUS_LABEL_Y, 0x2E7D32, 0.7F); drawScaledCenteredColumnText(guiGraphics, "Other player", STATUS_LABEL_Y, 0x2E7D32, 0.7F);
drawScaledCenteredColumnText(guiGraphics, "has accepted", STATUS_LABEL_Y + 7, 0x2E7D32, 0.7F); drawScaledCenteredColumnText(guiGraphics, "has accepted", STATUS_LABEL_Y + 7, 0x2E7D32, 0.7F);
} } else if (menu.view().selfAccepted()) {
if (menu.view().stage() == TradeStage.CONFIRMING) { if (menu.view().stage() == TradeStage.CONFIRMING) {
drawCenteredColumnText(guiGraphics, "FINAL", 16, 0x7A4F00); drawScaledCenteredColumnText(guiGraphics, "Waiting for", STATUS_LABEL_Y, 0x2E7D32, 0.7F);
drawCenteredColumnText(guiGraphics, "CONFIRM", 28, 0x7A4F00); 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 @Override
public void render(GuiGraphics guiGraphics, int mouseX, int mouseY, float partialTick) { public void render(GuiGraphics guiGraphics, int mouseX, int mouseY, float partialTick) {
if (acceptButton != null) {
acceptButton.setMessage(acceptButtonLabel());
}
super.render(guiGraphics, mouseX, mouseY, partialTick); super.render(guiGraphics, mouseX, mouseY, partialTick);
renderContextMenu(guiGraphics, mouseX, mouseY);
renderAmountPrompt(guiGraphics);
renderTooltip(guiGraphics, mouseX, mouseY); renderTooltip(guiGraphics, mouseX, mouseY);
} }
private void sendAction(TradeAction action, int slot, boolean secondary) { private Component acceptButtonLabel() {
return Component.literal(menu.view().stage() == TradeStage.CONFIRMING ? "Confirm" : "Accept");
}
private void sendAction(TradeAction action, int slot, int amount) {
if (minecraft == null || minecraft.getConnection() == null) { if (minecraft == null || minecraft.getConnection() == null) {
return; 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) { private void sendDebug(DebugControlAction action) {
sendDebug(action, "");
}
private void sendDebug(DebugControlAction action, String spec) {
if (minecraft == null || minecraft.getConnection() == null) { if (minecraft == null || minecraft.getConnection() == null) {
return; return;
} }
PacketDistributor.sendToServer(new DebugTradeControlPayload(menu.view().sessionId(), action, "")); PacketDistributor.sendToServer(new DebugTradeControlPayload(menu.view().sessionId(), action, spec));
} }
private void renderConfirmationOverlay(GuiGraphics guiGraphics) { private Button debugUnsafeButton(String label, int x, int y, DebugUnsafeState state) {
int panelLeft = leftPos + CONFIRM_PANEL_X; return Button.builder(Component.literal(label), button -> sendDebug(DebugControlAction.SET_UNSAFE, state.name()))
int panelTop = topPos + CONFIRM_PANEL_Y; .bounds(x, y, DEBUG_SMALL_BUTTON_WIDTH, DEBUG_BUTTON_HEIGHT)
guiGraphics.fill(leftPos, topPos, leftPos + imageWidth, topPos + imageHeight, 0x22000000); .build();
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 void drawCenteredColumnText(GuiGraphics guiGraphics, String text, int y, int color) { private void drawCenteredColumnText(GuiGraphics guiGraphics, String text, int y, int color) {
@@ -202,19 +294,205 @@ public class TradeScreen extends AbstractContainerScreen<TradeScreen.TradeMenu>
pose.popPose(); 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 { public static class TradeMenu extends AbstractContainerMenu {
private static final int OFFER_COLUMNS = 6; private static final int OFFER_COLUMNS = 6;
private static final int OFFER_ROWS = 6; private static final int OFFER_ROWS = 6;
private TradeView view; 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 selfOffer = new SimpleContainer(TradeView.OFFER_SLOT_COUNT);
private final SimpleContainer otherOffer = new SimpleContainer(TradeView.OFFER_SLOT_COUNT); private final SimpleContainer otherOffer = new SimpleContainer(TradeView.OFFER_SLOT_COUNT);
public TradeMenu(int containerId, Inventory inventory, TradeView view) { public TradeMenu(int containerId, Inventory inventory, TradeView view) {
super(MenuType.GENERIC_9x6, containerId); super(MenuType.GENERIC_9x6, containerId);
this.playerInventory = inventory;
this.view = view; this.view = view;
syncOfferContainers(); syncContainers();
for (int row = 0; row < OFFER_ROWS; row++) { for (int row = 0; row < OFFER_ROWS; row++) {
for (int col = 0; col < OFFER_COLUMNS; col++) { for (int col = 0; col < OFFER_COLUMNS; col++) {
@@ -233,12 +511,12 @@ public class TradeScreen extends AbstractContainerScreen<TradeScreen.TradeMenu>
for (int row = 0; row < 3; row++) { for (int row = 0; row < 3; row++) {
for (int col = 0; col < 9; col++) { for (int col = 0; col < 9; col++) {
int slot = col + row * 9 + 9; 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++) { 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 +524,15 @@ public class TradeScreen extends AbstractContainerScreen<TradeScreen.TradeMenu>
return view; return view;
} }
public Inventory playerInventory() {
return playerInventory;
}
public void updateView(TradeView view) { public void updateView(TradeView view) {
this.view = 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++) { for (int i = 0; i < TradeView.OFFER_SLOT_COUNT; i++) {
selfOffer.setItem(i, view.selfOffer().get(i).copy()); selfOffer.setItem(i, view.selfOffer().get(i).copy());
otherOffer.setItem(i, view.otherOffer().get(i).copy()); otherOffer.setItem(i, view.otherOffer().get(i).copy());
@@ -308,4 +585,24 @@ public class TradeScreen extends AbstractContainerScreen<TradeScreen.TradeMenu>
return false; 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;
}
@Override
public boolean mayPlace(ItemStack stack) {
return false;
}
}
} }

View File

@@ -1,10 +1,13 @@
package com.trunksbomb.trade.mod.command; package com.trunksbomb.trade.command;
import com.mojang.brigadier.CommandDispatcher; import com.mojang.brigadier.CommandDispatcher;
import com.mojang.brigadier.arguments.IntegerArgumentType;
import com.mojang.brigadier.arguments.StringArgumentType; import com.mojang.brigadier.arguments.StringArgumentType;
import com.mojang.brigadier.suggestion.SuggestionsBuilder; import com.mojang.brigadier.suggestion.SuggestionsBuilder;
import com.trunksbomb.trade.mod.trade.DebugTradeSession; import com.trunksbomb.trade.mod.TradeConfig;
import com.trunksbomb.trade.mod.trade.TradeManager; import com.trunksbomb.trade.trade.DebugUnsafeState;
import com.trunksbomb.trade.trade.DebugTradeSession;
import com.trunksbomb.trade.trade.TradeManager;
import java.util.concurrent.CompletableFuture; import java.util.concurrent.CompletableFuture;
import net.minecraft.commands.CommandSourceStack; import net.minecraft.commands.CommandSourceStack;
import net.minecraft.commands.Commands; import net.minecraft.commands.Commands;
@@ -15,9 +18,17 @@ public final class TradeCommand {
private TradeCommand() {} private TradeCommand() {}
public static void register(CommandDispatcher<CommandSourceStack> dispatcher) { public static void register(CommandDispatcher<CommandSourceStack> dispatcher) {
dispatcher.register(Commands.literal("trade") var trade = Commands.literal("trade")
.then(Commands.literal("debug") .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.literal("offer")
.then(Commands.argument("spec", StringArgumentType.greedyString()) .then(Commands.argument("spec", StringArgumentType.greedyString())
.executes(context -> setDebugOffer( .executes(context -> setDebugOffer(
@@ -28,12 +39,37 @@ public final class TradeCommand {
.executes(context -> removeDebugOffer( .executes(context -> removeDebugOffer(
context.getSource(), context.getSource(),
StringArgumentType.getString(context, "spec"))))) 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("accept").executes(context -> acceptDebug(context.getSource())))
.then(Commands.literal("cancel").executes(context -> cancelDebug(context.getSource()))) .then(Commands.literal("cancel").executes(context -> cancelDebug(context.getSource())))
.then(Commands.literal("close").executes(context -> closeDebug(context.getSource())))) .then(Commands.literal("close").executes(context -> closeDebug(context.getSource()))));
dispatcher.register(trade
.then(Commands.literal("yes").executes(context -> respondTrade(context.getSource(), true)))
.then(Commands.literal("no").executes(context -> respondTrade(context.getSource(), false)))
.then(Commands.literal("toggle").executes(context -> toggleTrade(context.getSource())))
.then(Commands.literal("on").executes(context -> setTradeEnabled(context.getSource(), true)))
.then(Commands.literal("off").executes(context -> setTradeEnabled(context.getSource(), false)))
.then(Commands.literal("ignore")
.then(Commands.argument("player", StringArgumentType.word())
.suggests((context, builder) -> suggestPlayers(context.getSource(), builder))
.executes(context -> ignorePlayer(context.getSource(), StringArgumentType.getString(context, "player")))))
.then(Commands.literal("unignore")
.then(Commands.argument("player", StringArgumentType.word())
.suggests((context, builder) -> suggestPlayers(context.getSource(), builder))
.executes(context -> unignorePlayer(context.getSource(), StringArgumentType.getString(context, "player")))))
.then(Commands.literal("ignorelist").executes(context -> listIgnoredPlayers(context.getSource())))
.then(Commands.argument("player", StringArgumentType.word()) .then(Commands.argument("player", StringArgumentType.word())
.suggests((context, builder) -> suggestPlayers(context.getSource(), builder)) .suggests((context, builder) -> suggestPlayers(context.getSource(), builder))
.executes(context -> startTrade(context.getSource(), StringArgumentType.getString(context, "player"))))); .executes(context -> requestTrade(context.getSource(), StringArgumentType.getString(context, "player")))));
} }
private static CompletableFuture<com.mojang.brigadier.suggestion.Suggestions> suggestPlayers(CommandSourceStack source, SuggestionsBuilder builder) { private static CompletableFuture<com.mojang.brigadier.suggestion.Suggestions> suggestPlayers(CommandSourceStack source, SuggestionsBuilder builder) {
@@ -45,7 +81,7 @@ public final class TradeCommand {
return builder.buildFuture(); 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)) { if (!(source.getEntity() instanceof ServerPlayer player)) {
source.sendFailure(Component.literal("Only players can start trades.")); source.sendFailure(Component.literal("Only players can start trades."));
return 0; return 0;
@@ -62,27 +98,157 @@ public final class TradeCommand {
return 0; return 0;
} }
if (!TradeManager.get(source.getServer()).startTrade(player, target)) { if (!TradeManager.get(source.getServer()).requestTrade(player, target)) {
source.sendFailure(Component.literal("Trade could not be started. One of you is already trading."));
return 0; return 0;
} }
source.sendSuccess(() -> Component.literal("Trade opened with " + target.getGameProfile().getName() + "."), false);
return 1; 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 initDebug(CommandSourceStack source, int delaySeconds) {
if (!(source.getEntity() instanceof ServerPlayer player)) { if (!(source.getEntity() instanceof ServerPlayer player)) {
source.sendFailure(Component.literal("Only players can start debug trades.")); source.sendFailure(Component.literal("Only players can start debug trades."));
return 0; 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.")); source.sendFailure(Component.literal("You are already in a trade."));
return 0; 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; return 1;
} }
@@ -143,6 +309,36 @@ public final class TradeCommand {
return runDebugAction(source, TradeManager.get(source.getServer())::closeDebug, "Debug trade closed."); 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) { private static int runDebugAction(CommandSourceStack source, java.util.function.Predicate<ServerPlayer> action, String successMessage) {
if (!(source.getEntity() instanceof ServerPlayer player)) { if (!(source.getEntity() instanceof ServerPlayer player)) {
source.sendFailure(Component.literal("Only players can control debug trades.")); source.sendFailure(Component.literal("Only players can control debug trades."));

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; 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.client.Minecraft;
import net.minecraft.network.chat.Component;
import net.minecraft.world.entity.player.Player;
import net.neoforged.api.distmarker.Dist; import net.neoforged.api.distmarker.Dist;
import net.neoforged.bus.api.SubscribeEvent; import net.neoforged.bus.api.IEventBus;
import net.neoforged.fml.common.EventBusSubscriber;
import net.neoforged.fml.common.Mod; 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) @Mod(value = TradeMod.MODID, dist = Dist.CLIENT)
@EventBusSubscriber(modid = TradeMod.MODID, value = Dist.CLIENT)
public class TradeClientMod { public class TradeClientMod {
@SubscribeEvent private static final KeyMapping REQUEST_TRADE = new KeyMapping(
static void onClientSetup(FMLClientSetupEvent event) { "key.trade.request_trade",
event.enqueueWork(() -> TradeClientState.init(Minecraft.getInstance())); 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,124 @@
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.BooleanValue ENABLE_DEBUG_FEATURES;
private static final ModConfigSpec.BooleanValue REQUIRE_ON_GROUND;
private static final ModConfigSpec.BooleanValue REQUIRE_STATIONARY;
private static final ModConfigSpec.DoubleValue STATIONARY_SPEED_THRESHOLD;
private static final ModConfigSpec.BooleanValue REQUIRE_NO_RECENT_DAMAGE;
private static final ModConfigSpec.IntValue NO_DAMAGE_SECONDS;
private static final ModConfigSpec.BooleanValue REQUIRE_NOT_ON_FIRE;
private static final ModConfigSpec.BooleanValue REQUIRE_NOT_IN_LIQUID;
private static final ModConfigSpec.BooleanValue REQUIRE_NOT_SLEEPING;
private static final ModConfigSpec.BooleanValue REQUIRE_NOT_FALL_FLYING;
private static final ModConfigSpec.BooleanValue REQUIRE_NOT_RIDING;
private static final ModConfigSpec.BooleanValue REQUIRE_SAME_DIMENSION;
static {
ModConfigSpec.Builder builder = new ModConfigSpec.Builder();
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);
ENABLE_DEBUG_FEATURES = builder.comment("Enable debug trade commands and debug UI/testing tools.")
.define("enableDebugFeatures", false);
REQUIRE_ON_GROUND = builder.comment("Require players to be on solid ground before requesting or accepting a trade.")
.define("requireOnGround", true);
REQUIRE_STATIONARY = builder.comment("Require players to be stationary before requesting or accepting a trade.")
.define("requireStationary", true);
STATIONARY_SPEED_THRESHOLD = builder.comment("Maximum horizontal player speed considered stationary.")
.defineInRange("stationarySpeedThreshold", 0.03D, 0.0D, 10.0D);
REQUIRE_NO_RECENT_DAMAGE = builder.comment("Require players to have taken no damage recently before requesting or accepting a trade.")
.define("requireNoRecentDamage", true);
NO_DAMAGE_SECONDS = builder.comment("How long a player must be out of combat to trade when requireNoRecentDamage is enabled.")
.defineInRange("noDamageSeconds", 10, 0, 3600);
REQUIRE_NOT_ON_FIRE = builder.comment("Require players to not be on fire before requesting or accepting a trade.")
.define("requireNotOnFire", true);
REQUIRE_NOT_IN_LIQUID = builder.comment("Require players to not be in water or lava before requesting or accepting a trade.")
.define("requireNotInLiquid", true);
REQUIRE_NOT_SLEEPING = builder.comment("Require players to not be sleeping before requesting or accepting a trade.")
.define("requireNotSleeping", true);
REQUIRE_NOT_FALL_FLYING = builder.comment("Require players to not be gliding with elytra before requesting or accepting a trade.")
.define("requireNotFallFlying", true);
REQUIRE_NOT_RIDING = builder.comment("Require players to not be riding another entity before requesting or accepting a trade.")
.define("requireNotRiding", true);
REQUIRE_SAME_DIMENSION = builder.comment("Require both players to be in the same dimension.")
.define("requireSameDimension", true);
builder.pop();
SPEC = builder.build();
}
private TradeConfig() {}
public static int tradeCommandProximity() {
return TRADE_COMMAND_PROXIMITY.get();
}
public static int requestTimeoutSeconds() {
return REQUEST_TIMEOUT_SECONDS.get();
}
public static boolean enableDebugFeatures() {
return ENABLE_DEBUG_FEATURES.get();
}
public static boolean enableDebugFeaturesSafe() {
try {
return ENABLE_DEBUG_FEATURES.get();
} catch (IllegalStateException ignored) {
return false;
}
}
public static boolean requireOnGround() {
return REQUIRE_ON_GROUND.get();
}
public static boolean requireStationary() {
return REQUIRE_STATIONARY.get();
}
public static double stationarySpeedThreshold() {
return STATIONARY_SPEED_THRESHOLD.get();
}
public static boolean requireNoRecentDamage() {
return REQUIRE_NO_RECENT_DAMAGE.get();
}
public static int noDamageSeconds() {
return NO_DAMAGE_SECONDS.get();
}
public static boolean requireNotOnFire() {
return REQUIRE_NOT_ON_FIRE.get();
}
public static boolean requireNotInLiquid() {
return REQUIRE_NOT_IN_LIQUID.get();
}
public static boolean requireNotSleeping() {
return REQUIRE_NOT_SLEEPING.get();
}
public static boolean requireNotFallFlying() {
return REQUIRE_NOT_FALL_FLYING.get();
}
public static boolean requireNotRiding() {
return REQUIRE_NOT_RIDING.get();
}
public static boolean requireSameDimension() {
return REQUIRE_SAME_DIMENSION.get();
}
}

View File

@@ -1,10 +1,11 @@
package com.trunksbomb.trade.mod; package com.trunksbomb.trade.mod;
import com.mojang.logging.LogUtils; import com.mojang.logging.LogUtils;
import com.trunksbomb.trade.mod.command.TradeCommand; import com.trunksbomb.trade.command.TradeCommand;
import com.trunksbomb.trade.mod.network.TradeNetworking; import com.trunksbomb.trade.network.TradeNetworking;
import com.trunksbomb.trade.mod.trade.TradeManager; import com.trunksbomb.trade.trade.TradeManager;
import net.neoforged.bus.api.IEventBus; import net.neoforged.bus.api.IEventBus;
import net.neoforged.fml.ModContainer;
import net.neoforged.fml.common.Mod; import net.neoforged.fml.common.Mod;
import net.neoforged.fml.event.lifecycle.FMLCommonSetupEvent; import net.neoforged.fml.event.lifecycle.FMLCommonSetupEvent;
import net.neoforged.neoforge.common.NeoForge; import net.neoforged.neoforge.common.NeoForge;
@@ -18,11 +19,15 @@ public class TradeMod {
public static final String MODID = "trade"; public static final String MODID = "trade";
public static final Logger LOGGER = LogUtils.getLogger(); public static final Logger LOGGER = LogUtils.getLogger();
public TradeMod(IEventBus modEventBus) { public TradeMod(IEventBus modEventBus, ModContainer modContainer) {
modEventBus.addListener(this::commonSetup); modEventBus.addListener(this::commonSetup);
modEventBus.addListener(this::registerPayloads); 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::registerCommands);
NeoForge.EVENT_BUS.addListener(this::onPlayerLogout); 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) { private void commonSetup(FMLCommonSetupEvent event) {
@@ -43,4 +48,21 @@ public class TradeMod {
TradeManager.get(player.server).handleDisconnect(player); 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.TradeMod;
import com.trunksbomb.trade.mod.trade.DebugControlAction; import com.trunksbomb.trade.trade.DebugControlAction;
import java.util.UUID; import java.util.UUID;
import net.minecraft.network.RegistryFriendlyByteBuf; import net.minecraft.network.RegistryFriendlyByteBuf;
import net.minecraft.network.codec.StreamCodec; 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.TradeMod;
import com.trunksbomb.trade.mod.trade.TradeAction; import com.trunksbomb.trade.trade.TradeAction;
import java.util.UUID; import java.util.UUID;
import net.minecraft.network.RegistryFriendlyByteBuf; import net.minecraft.network.RegistryFriendlyByteBuf;
import net.minecraft.network.codec.StreamCodec; import net.minecraft.network.codec.StreamCodec;
import net.minecraft.network.protocol.common.custom.CustomPacketPayload; import net.minecraft.network.protocol.common.custom.CustomPacketPayload;
import net.minecraft.resources.ResourceLocation; 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 Type<TradeActionPayload> TYPE = new Type<>(ResourceLocation.fromNamespaceAndPath(TradeMod.MODID, "trade_action"));
public static final StreamCodec<RegistryFriendlyByteBuf, TradeActionPayload> STREAM_CODEC = new StreamCodec<>() { public static final StreamCodec<RegistryFriendlyByteBuf, TradeActionPayload> STREAM_CODEC = new StreamCodec<>() {
@Override @Override
@@ -16,12 +16,12 @@ public record TradeActionPayload(UUID sessionId, TradeAction action, int slot, b
buf.writeUUID(value.sessionId); buf.writeUUID(value.sessionId);
buf.writeEnum(value.action); buf.writeEnum(value.action);
buf.writeVarInt(value.slot); buf.writeVarInt(value.slot);
buf.writeBoolean(value.secondary); buf.writeVarInt(value.amount);
} }
@Override @Override
public TradeActionPayload decode(RegistryFriendlyByteBuf buf) { 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 com.trunksbomb.trade.mod.TradeMod;
import java.util.UUID; 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.trade.TradeManager;
import com.trunksbomb.trade.mod.trade.TradeManager;
import net.minecraft.client.Minecraft;
import net.minecraft.network.chat.Component; import net.minecraft.network.chat.Component;
import net.minecraft.server.level.ServerPlayer; import net.minecraft.server.level.ServerPlayer;
import net.neoforged.neoforge.network.handling.DirectionalPayloadHandler; import net.neoforged.neoforge.network.handling.DirectionalPayloadHandler;
@@ -25,6 +23,10 @@ public final class TradeNetworking {
TradeActionPayload.TYPE, TradeActionPayload.TYPE,
TradeActionPayload.STREAM_CODEC, TradeActionPayload.STREAM_CODEC,
TradeNetworking::handleTradeActionServer); TradeNetworking::handleTradeActionServer);
registrar.playToServer(
TradeRequestPayload.TYPE,
TradeRequestPayload.STREAM_CODEC,
TradeNetworking::handleTradeRequestServer);
registrar.playToServer( registrar.playToServer(
DebugTradeControlPayload.TYPE, DebugTradeControlPayload.TYPE,
DebugTradeControlPayload.STREAM_CODEC, DebugTradeControlPayload.STREAM_CODEC,
@@ -32,39 +34,32 @@ public final class TradeNetworking {
} }
private static void handleTradeStateClient(TradeStatePayload payload, IPayloadContext context) { private static void handleTradeStateClient(TradeStatePayload payload, IPayloadContext context) {
Minecraft minecraft = Minecraft.getInstance(); invokeClientState("showOrUpdate", new Class<?>[] {payload.view().getClass()}, payload.view());
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")));
} }
private static void handleTradeCloseClient(TradeClosePayload payload, IPayloadContext context) { private static void handleTradeCloseClient(TradeClosePayload payload, IPayloadContext context) {
Minecraft minecraft = Minecraft.getInstance(); invokeClientState("closeTrade", new Class<?>[] {java.util.UUID.class, Component.class}, payload.sessionId(), payload.reason());
if (minecraft.screen instanceof TradeScreen screen && screen.sessionId().equals(payload.sessionId())) {
minecraft.setScreen(null);
}
if (minecraft.player != null) {
minecraft.player.displayClientMessage(payload.reason(), false);
}
} }
private static void handleTradeActionServer(TradeActionPayload payload, IPayloadContext context) { private static void handleTradeActionServer(TradeActionPayload payload, IPayloadContext context) {
if (context.player() instanceof ServerPlayer player) { 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) { private static void handleDebugTradeControlServer(DebugTradeControlPayload payload, IPayloadContext context) {
if (context.player() instanceof ServerPlayer player) { if (context.player() instanceof ServerPlayer player) {
TradeManager.get(player.server).handleDebugControl(player, payload.sessionId(), payload.action(), payload.spec()); 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 ignoreTradeStateServer(TradeStatePayload payload, IPayloadContext context) {}
private static void ignoreTradeCloseServer(TradeClosePayload 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.TradeMod;
import com.trunksbomb.trade.mod.trade.TradeView; import com.trunksbomb.trade.trade.TradeView;
import net.minecraft.network.RegistryFriendlyByteBuf; import net.minecraft.network.RegistryFriendlyByteBuf;
import net.minecraft.network.codec.StreamCodec; import net.minecraft.network.codec.StreamCodec;
import net.minecraft.network.protocol.common.custom.CustomPacketPayload; 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 { public enum DebugControlAction {
SET_OFFER, SET_OFFER,
APPEND_RANDOM, APPEND_RANDOM,
REMOVE_OFFER, REMOVE_OFFER,
REMOVE_LAST, REMOVE_LAST,
SET_UNSAFE,
CLEAR_UNSAFE,
ACCEPT, ACCEPT,
CANCEL, CANCEL,
CLOSE CLOSE

View File

@@ -1,8 +1,9 @@
package com.trunksbomb.trade.mod.trade; package com.trunksbomb.trade.trade;
import com.trunksbomb.trade.mod.network.TradeClosePayload; import com.trunksbomb.trade.network.TradeClosePayload;
import com.trunksbomb.trade.mod.network.TradeStatePayload; import com.trunksbomb.trade.network.TradeStatePayload;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.EnumSet;
import java.util.List; import java.util.List;
import java.util.UUID; import java.util.UUID;
import net.minecraft.core.registries.BuiltInRegistries; import net.minecraft.core.registries.BuiltInRegistries;
@@ -22,14 +23,17 @@ public class DebugTradeSession {
private final UUID id = UUID.randomUUID(); private final UUID id = UUID.randomUUID();
private final ServerPlayer player; 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<ItemStack> otherOffer = blankStacks();
private final EnumSet<DebugUnsafeState> unsafeStates = EnumSet.noneOf(DebugUnsafeState.class);
private boolean selfAccepted; private boolean selfAccepted;
private boolean otherAccepted; private boolean otherAccepted;
private TradeStage stage = TradeStage.OFFERING; private TradeStage stage = TradeStage.OFFERING;
public DebugTradeSession(ServerPlayer player) { public DebugTradeSession(ServerPlayer player) {
this.player = player; this.player = player;
this.inventorySnapshot = inventorySnapshot(player);
} }
public UUID id() { public UUID id() {
@@ -40,6 +44,22 @@ public class DebugTradeSession {
return player; 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() { public void sync() {
PacketDistributor.sendToPlayer(player, new TradeStatePayload(view())); PacketDistributor.sendToPlayer(player, new TradeStatePayload(view()));
} }
@@ -48,59 +68,60 @@ public class DebugTradeSession {
PacketDistributor.sendToPlayer(player, new TradeClosePayload(id, reason)); 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) { if (stage != TradeStage.OFFERING || inventorySlot < 0 || inventorySlot >= INVENTORY_SLOT_COUNT) {
return false; return false;
} }
Inventory inventory = player.getInventory(); ItemStack sourceStack = inventorySnapshot.get(inventorySlot);
ItemStack sourceStack = inventory.getItem(inventorySlot);
if (sourceStack.isEmpty()) { if (sourceStack.isEmpty()) {
return false; return false;
} }
ItemStack moving = sourceStack.copyWithCount(singleItem ? 1 : sourceStack.getCount()); int available = sourceStack.getCount() - reservedCount(inventorySlot);
if (!insertStack(selfOffer, moving)) { if (available <= 0) {
return false; return false;
} }
sourceStack.shrink(singleItem ? 1 : sourceStack.getCount()); int moveAmount = Math.max(1, Math.min(amount, available));
if (sourceStack.isEmpty()) { for (int i = 0; i < selfOffer.size(); i++) {
inventory.setItem(inventorySlot, ItemStack.EMPTY); 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();
return true;
}
} }
inventory.setChanged();
int freeSlot = firstFreeOfferSlot(selfOffer);
if (freeSlot == -1) {
return false;
}
selfOffer.set(freeSlot, new TradeEntry(inventorySlot, sourceStack.copyWithCount(moveAmount)));
clearAccepts(); clearAccepts();
return true; 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) { if (stage != TradeStage.OFFERING || offerSlot < 0 || offerSlot >= OFFER_SLOT_COUNT) {
return false; return false;
} }
ItemStack offered = selfOffer.get(offerSlot); TradeEntry entry = selfOffer.get(offerSlot);
if (offered.isEmpty()) { if (entry == null) {
return false; return false;
} }
List<ItemStack> workingInventory = inventorySnapshot(); int moveAmount = Math.max(1, Math.min(amount, entry.stack().getCount()));
ItemStack returned = offered.copyWithCount(singleItem ? 1 : offered.getCount()); if (moveAmount < entry.stack().getCount()) {
if (!insertStack(workingInventory, returned)) { ItemStack updated = entry.stack().copy();
return false; updated.shrink(moveAmount);
} selfOffer.set(offerSlot, new TradeEntry(entry.sourceSlot(), updated));
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);
} else { } else {
selfOffer.set(offerSlot, ItemStack.EMPTY); selfOffer.set(offerSlot, null);
} }
clearAccepts(); clearAccepts();
@@ -189,6 +210,22 @@ public class DebugTradeSession {
return false; 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() { public void advanceToConfirmation() {
if (stage == TradeStage.OFFERING && selfAccepted && otherAccepted) { if (stage == TradeStage.OFFERING && selfAccepted && otherAccepted) {
stage = TradeStage.CONFIRMING; stage = TradeStage.CONFIRMING;
@@ -206,7 +243,11 @@ public class DebugTradeSession {
} }
public boolean completeTrade() { public boolean completeTrade() {
List<ItemStack> result = inventorySnapshot(); List<ItemStack> result = inventorySnapshot(player);
if (!removeOutgoing(result, selfOffer)) {
return false;
}
for (ItemStack stack : otherOffer) { for (ItemStack stack : otherOffer) {
if (!stack.isEmpty() && !insertStack(result, stack.copy())) { if (!stack.isEmpty() && !insertStack(result, stack.copy())) {
return false; return false;
@@ -230,7 +271,7 @@ public class DebugTradeSession {
stage, stage,
selfAccepted, selfAccepted,
otherAccepted, otherAccepted,
inventorySnapshot(), inventoryDisplay(),
emptyReservedSnapshot(), emptyReservedSnapshot(),
selfOfferSnapshot(), selfOfferSnapshot(),
otherOfferSnapshot()); otherOfferSnapshot());
@@ -310,6 +351,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) { private static boolean insertStack(List<ItemStack> working, ItemStack incoming) {
if (incoming.isEmpty()) { if (incoming.isEmpty()) {
return true; return true;
@@ -348,6 +408,49 @@ public class DebugTradeSession {
return false; 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() {
selfAccepted = false;
otherAccepted = 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() { private static List<Integer> emptyReservedSnapshot() {
List<Integer> result = new ArrayList<>(INVENTORY_SLOT_COUNT); List<Integer> result = new ArrayList<>(INVENTORY_SLOT_COUNT);
for (int i = 0; i < INVENTORY_SLOT_COUNT; i++) { for (int i = 0; i < INVENTORY_SLOT_COUNT; i++) {
@@ -356,12 +459,7 @@ public class DebugTradeSession {
return result; return result;
} }
private void clearAccepts() { private static List<ItemStack> inventorySnapshot(ServerPlayer player) {
selfAccepted = false;
otherAccepted = false;
}
private List<ItemStack> inventorySnapshot() {
List<ItemStack> result = new ArrayList<>(INVENTORY_SLOT_COUNT); List<ItemStack> result = new ArrayList<>(INVENTORY_SLOT_COUNT);
Inventory inventory = player.getInventory(); Inventory inventory = player.getInventory();
for (int i = 0; i < INVENTORY_SLOT_COUNT; i++) { for (int i = 0; i < INVENTORY_SLOT_COUNT; i++) {
@@ -370,26 +468,10 @@ public class DebugTradeSession {
return result; return result;
} }
private List<ItemStack> selfOfferSnapshot() { private static List<TradeEntry> blankOffer() {
List<ItemStack> result = new ArrayList<>(OFFER_SLOT_COUNT); List<TradeEntry> 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);
for (int i = 0; i < OFFER_SLOT_COUNT; i++) { for (int i = 0; i < OFFER_SLOT_COUNT; i++) {
result.add(ItemStack.EMPTY); result.add(null);
} }
return result; return result;
} }
@@ -402,4 +484,12 @@ public class DebugTradeSession {
return result; 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 { public enum TradeAction {
ADD_ITEM, ADD_ITEM,

View File

@@ -1,4 +1,4 @@
package com.trunksbomb.trade.mod.trade; package com.trunksbomb.trade.trade;
import net.minecraft.world.item.ItemStack; 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,7 @@
package com.trunksbomb.trade.mod.trade; package com.trunksbomb.trade.trade;
import com.trunksbomb.trade.mod.network.TradeClosePayload; import com.trunksbomb.trade.network.TradeClosePayload;
import com.trunksbomb.trade.mod.network.TradeStatePayload; import com.trunksbomb.trade.network.TradeStatePayload;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.List; import java.util.List;
import java.util.UUID; import java.util.UUID;
@@ -10,6 +10,7 @@ import net.minecraft.server.level.ServerPlayer;
import net.minecraft.world.entity.player.Inventory; import net.minecraft.world.entity.player.Inventory;
import net.minecraft.world.item.ItemStack; import net.minecraft.world.item.ItemStack;
import net.neoforged.neoforge.network.PacketDistributor; import net.neoforged.neoforge.network.PacketDistributor;
import com.trunksbomb.trade.mod.TradeConfig;
public class TradeSession { public class TradeSession {
private static final int INVENTORY_SLOT_COUNT = TradeView.INVENTORY_SLOT_COUNT; private static final int INVENTORY_SLOT_COUNT = TradeView.INVENTORY_SLOT_COUNT;
@@ -18,8 +19,10 @@ public class TradeSession {
private final UUID id = UUID.randomUUID(); private final UUID id = UUID.randomUUID();
private final ServerPlayer first; private final ServerPlayer first;
private final ServerPlayer second; private final ServerPlayer second;
private final List<ItemStack> firstOffer = blankOffer(); private final List<ItemStack> firstInventory;
private final List<ItemStack> secondOffer = blankOffer(); private final List<ItemStack> secondInventory;
private final List<TradeEntry> firstOffer = blankOffer();
private final List<TradeEntry> secondOffer = blankOffer();
private boolean firstAccepted; private boolean firstAccepted;
private boolean secondAccepted; private boolean secondAccepted;
private TradeStage stage = TradeStage.OFFERING; private TradeStage stage = TradeStage.OFFERING;
@@ -27,6 +30,8 @@ public class TradeSession {
public TradeSession(ServerPlayer first, ServerPlayer second) { public TradeSession(ServerPlayer first, ServerPlayer second) {
this.first = first; this.first = first;
this.second = second; this.second = second;
this.firstInventory = inventorySnapshot(first);
this.secondInventory = inventorySnapshot(second);
} }
public UUID id() { public UUID id() {
@@ -41,6 +46,14 @@ public class TradeSession {
return second; return second;
} }
public List<ItemStack> firstOfferSnapshot() {
return offerSnapshot(firstOffer);
}
public List<ItemStack> secondOfferSnapshot() {
return offerSnapshot(secondOffer);
}
public boolean involves(ServerPlayer player) { public boolean involves(ServerPlayer player) {
return player == first || player == second; return player == first || player == second;
} }
@@ -59,58 +72,64 @@ public class TradeSession {
PacketDistributor.sendToPlayer(second, new TradeClosePayload(id, reason)); PacketDistributor.sendToPlayer(second, new TradeClosePayload(id, reason));
} }
public boolean addFromInventory(ServerPlayer player, int inventorySlot, boolean singleItem) { public boolean addFromInventory(ServerPlayer player, int inventorySlot, int amount) {
if (stage != TradeStage.OFFERING || inventorySlot < 0 || inventorySlot >= INVENTORY_SLOT_COUNT) { if (stage != TradeStage.OFFERING || inventorySlot < 0 || inventorySlot >= INVENTORY_SLOT_COUNT) {
return false; return false;
} }
List<ItemStack> offer = offerFor(player); ItemStack sourceStack = inventoryFor(player).get(inventorySlot);
Inventory inventory = player.getInventory();
ItemStack sourceStack = inventory.getItem(inventorySlot);
if (sourceStack.isEmpty()) { if (sourceStack.isEmpty()) {
return false; return false;
} }
ItemStack moving = sourceStack.copyWithCount(singleItem ? 1 : sourceStack.getCount()); int available = sourceStack.getCount() - reservedCount(player, inventorySlot);
if (!insertStack(offer, moving)) { if (available <= 0) {
return false; return false;
} }
sourceStack.shrink(singleItem ? 1 : sourceStack.getCount()); int moveAmount = Math.max(1, Math.min(amount, available));
if (sourceStack.isEmpty()) { List<TradeEntry> offer = offerFor(player);
inventory.setItem(inventorySlot, ItemStack.EMPTY); 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();
return true;
}
} }
inventory.setChanged();
int freeSlot = firstFreeOfferSlot(offer);
if (freeSlot == -1) {
return false;
}
offer.set(freeSlot, new TradeEntry(inventorySlot, sourceStack.copyWithCount(moveAmount)));
clearAccepts(); clearAccepts();
return true; 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) { if (stage != TradeStage.OFFERING || offerSlot < 0 || offerSlot >= OFFER_SLOT_COUNT) {
return false; return false;
} }
List<ItemStack> offer = offerFor(player); List<TradeEntry> offer = offerFor(player);
ItemStack offered = offer.get(offerSlot); TradeEntry entry = offer.get(offerSlot);
if (offered.isEmpty()) { if (entry == null) {
return false; return false;
} }
Inventory inventory = player.getInventory(); int moveAmount = Math.max(1, Math.min(amount, entry.stack().getCount()));
List<ItemStack> workingInventory = inventorySnapshot(player); if (moveAmount < entry.stack().getCount()) {
ItemStack returned = offered.copyWithCount(singleItem ? 1 : offered.getCount()); ItemStack updated = entry.stack().copy();
if (!insertStack(workingInventory, returned)) { updated.shrink(moveAmount);
return false; offer.set(offerSlot, new TradeEntry(entry.sourceSlot(), updated));
}
applyInventory(player, workingInventory);
if (singleItem && offered.getCount() > 1) {
ItemStack updated = offered.copy();
updated.shrink(1);
offer.set(offerSlot, updated);
} else { } else {
offer.set(offerSlot, ItemStack.EMPTY); offer.set(offerSlot, null);
} }
clearAccepts(); clearAccepts();
return true; return true;
} }
@@ -139,18 +158,36 @@ public class TradeSession {
return stage == TradeStage.CONFIRMING; return stage == TradeStage.CONFIRMING;
} }
public boolean completeTrade() { public CompletionResult completeTrade() {
List<ItemStack> firstResult = simulateResult(first, secondOffer); if (first.level() != second.level()) {
List<ItemStack> secondResult = simulateResult(second, firstOffer); 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) { 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(first, firstResult);
applyInventory(second, secondResult); applyInventory(second, secondResult);
clearOffer(firstOffer); return CompletionResult.success();
clearOffer(secondOffer);
return true;
} }
public TradeView viewFor(ServerPlayer player) { public TradeView viewFor(ServerPlayer player) {
@@ -164,7 +201,7 @@ public class TradeSession {
stage, stage,
isFirst ? firstAccepted : secondAccepted, isFirst ? firstAccepted : secondAccepted,
isFirst ? secondAccepted : firstAccepted, isFirst ? secondAccepted : firstAccepted,
inventorySnapshot(player), inventoryDisplayFor(player),
emptyReservedSnapshot(), emptyReservedSnapshot(),
offerSnapshot(isFirst ? firstOffer : secondOffer), offerSnapshot(isFirst ? firstOffer : secondOffer),
offerSnapshot(isFirst ? secondOffer : firstOffer)); offerSnapshot(isFirst ? secondOffer : firstOffer));
@@ -174,10 +211,14 @@ public class TradeSession {
PacketDistributor.sendToPlayer(player, new TradeStatePayload(viewFor(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); List<ItemStack> working = inventorySnapshot(player);
for (ItemStack stack : incoming) { if (!removeOutgoing(working, outgoing)) {
if (!stack.isEmpty() && !insertStack(working, stack.copy())) { return null;
}
for (TradeEntry entry : incoming) {
if (entry != null && !insertStack(working, entry.stack().copy())) {
return null; return null;
} }
} }
@@ -185,6 +226,25 @@ public class TradeSession {
return working; 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) { private static boolean insertStack(List<ItemStack> working, ItemStack incoming) {
if (incoming.isEmpty()) { if (incoming.isEmpty()) {
return true; return true;
@@ -223,12 +283,6 @@ public class TradeSession {
return false; 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) { private static void applyInventory(ServerPlayer player, List<ItemStack> result) {
Inventory inventory = player.getInventory(); Inventory inventory = player.getInventory();
for (int i = 0; i < result.size(); i++) { for (int i = 0; i < result.size(); i++) {
@@ -237,19 +291,84 @@ public class TradeSession {
inventory.setChanged(); inventory.setChanged();
} }
private static List<ItemStack> blankOffer() { private static boolean inventoryMatchesSnapshot(ServerPlayer player, List<ItemStack> snapshot) {
List<ItemStack> result = new ArrayList<>(OFFER_SLOT_COUNT); 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++) { for (int i = 0; i < OFFER_SLOT_COUNT; i++) {
result.add(ItemStack.EMPTY); result.add(null);
} }
return result; 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;
}
private void clearAccepts() { private void clearAccepts() {
firstAccepted = false; firstAccepted = false;
secondAccepted = false; secondAccepted = 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() { private static List<Integer> emptyReservedSnapshot() {
List<Integer> result = new ArrayList<>(INVENTORY_SLOT_COUNT); List<Integer> result = new ArrayList<>(INVENTORY_SLOT_COUNT);
for (int i = 0; i < INVENTORY_SLOT_COUNT; i++) { for (int i = 0; i < INVENTORY_SLOT_COUNT; i++) {
@@ -258,10 +377,6 @@ public class TradeSession {
return result; return result;
} }
private List<ItemStack> offerFor(ServerPlayer player) {
return player == first ? firstOffer : secondOffer;
}
private static List<ItemStack> inventorySnapshot(ServerPlayer player) { private static List<ItemStack> inventorySnapshot(ServerPlayer player) {
List<ItemStack> result = new ArrayList<>(INVENTORY_SLOT_COUNT); List<ItemStack> result = new ArrayList<>(INVENTORY_SLOT_COUNT);
Inventory inventory = player.getInventory(); Inventory inventory = player.getInventory();
@@ -271,11 +386,21 @@ public class TradeSession {
return result; 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); List<ItemStack> result = new ArrayList<>(OFFER_SLOT_COUNT);
for (ItemStack stack : offer) { for (TradeEntry entry : offer) {
result.add(stack.copy()); result.add(entry == null ? ItemStack.EMPTY : entry.stack().copy());
} }
return result; 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 { public enum TradeStage {
OFFERING, 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.ArrayList;
import java.util.List; import java.util.List;

View File

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