diff --git a/common/src/generated/resources/.cache/d6a1ec2d08c6d6d7facbde77dda6f0158c00bbd6 b/common/src/generated/resources/.cache/d6a1ec2d08c6d6d7facbde77dda6f0158c00bbd6 index f75f4816..7e5dc5a0 100644 --- a/common/src/generated/resources/.cache/d6a1ec2d08c6d6d7facbde77dda6f0158c00bbd6 +++ b/common/src/generated/resources/.cache/d6a1ec2d08c6d6d7facbde77dda6f0158c00bbd6 @@ -1,4 +1,22 @@ -// 1.20.1 2024-04-19T18:54:51.676973276 Create: Numismatics/Registrate Provider for numismatics [Recipes, Advancements, Loot Tables, Tags (blocks), Tags (items), Tags (fluids), Tags (entity_types), Blockstates, Item models, Lang (en_us/en_ud)] +// 1.21.1 2025-08-11T14:40:38.617334 Registrate Provider for numismatics [Registries, Data Maps, Recipes, Advancements, Loot Tables, Tags (enchantments), Tags (blocks), Tags (items), Tags (fluids), Tags (entity_types), generic_server_provider, Blockstates, Item models, Lang (en_us/en_ud), generic_client_provider] +f7f43dd6d567ec8303c73b79409bc92d8b56574a assets/numismatics/blockstates/andesite_depositor.json +3961fdf3030140fc32e0e8c1d440ac395e62f5b6 assets/numismatics/blockstates/bank_terminal.json +06ecd28cd97f4e8200dc396858695cad57b871c8 assets/numismatics/blockstates/blaze_banker.json +160d556c6bfdb651082b39784258f6d06c21ca8f assets/numismatics/blockstates/brass_depositor.json +95ef415a564eba1d212053195d25b199427b94e3 assets/numismatics/blockstates/creative_vendor.json +d2b105f0657bad99b8efed45dc0a8df8ff775c10 assets/numismatics/blockstates/vendor.json +da2ea46b51689cb1062f072ab847b8e164a414e2 assets/numismatics/lang/en_ud.json +56639735048e12545395308eb903659b052b2d9e assets/numismatics/lang/en_us.json +265ef24d62bc7580e763e1fb6802bf4e58dc0194 assets/numismatics/models/block/andesite_depositor.json +4f78ca868db20495aa20be7c6a14e2678fb16f9f assets/numismatics/models/block/andesite_depositor_locked.json +411b79f79547a0adcb665bf7440e8169f7dcb24e assets/numismatics/models/block/brass_depositor.json +74a4c7ca7a48382782e5dba33018dfc8255192c5 assets/numismatics/models/block/brass_depositor_locked.json +2449b7346e1657ef1c6ab4c134aab55b216ec783 assets/numismatics/models/item/andesite_depositor.json +83ce6c9d27970b4c643f0f9f3dfeb58668fca3d4 assets/numismatics/models/item/banking_guide.json +228b67a48aa045bfe809c54c756df80eb0765aad assets/numismatics/models/item/bank_terminal.json +52b48750de8a5a571a08bce3f2f025474153d50b assets/numismatics/models/item/bevel.json +84ab8c91452f94501b3acc31ec1e0bc64417f839 assets/numismatics/models/item/black_card.json +>>>>>>> 646131b (Shopkeeper Integration):common/src/generated/resources/.cache/64c87e664647124a7b6450bc49fb722594ed5a16 70c481f36a9718ac48632e6939ac6ba785be4c9e assets/numismatics/models/item/black_id_card.json 95ef415a564eba1d212053195d25b199427b94e3 assets/numismatics/blockstates/creative_vendor.json 4458283178334ae169a7cbbd1aa09067cbb99ee7 data/numismatics/tags/items/internal/dyes/green_dyes.json @@ -92,6 +110,27 @@ f7f43dd6d567ec8303c73b79409bc92d8b56574a assets/numismatics/blockstates/andesite ce821bcccb920fd51237904e253fb29100882648 data/numismatics/tags/items/internal/dyes/brown_dyes.json 74a4c7ca7a48382782e5dba33018dfc8255192c5 assets/numismatics/models/block/brass_depositor_locked.json 95b492bd9230dc90fca9395c823cef39e644d8f2 assets/numismatics/models/item/sprocket.json +facbd710d107ebc9b2c6ddfa3b59a16d5f85c992 assets/numismatics/models/item/spur.json +8fd12493390894fa5b3988f499f758c17137af16 assets/numismatics/models/item/sun.json +6965cf99471bb8c63f5f8a94577e2cddc3b2bc33 assets/numismatics/models/item/vendor.json +c1863c2bd08a5910a534aee0dcbc61a352fb9577 assets/numismatics/models/item/white_card.json +a96d3d02794064cd9be1bca25a9ba6217675e6c5 assets/numismatics/models/item/white_id_card.json +9c20dd40c03605721d0231ffde829d55e36b1c05 assets/numismatics/models/item/yellow_card.json +c05836600bd1689f598515841869634b1d709cca assets/numismatics/models/item/yellow_id_card.json +b8a840be34886ce90bc6ebbd48ac70a40060ada1 data/create/tags/block/fan_transparent.json +b8a840be34886ce90bc6ebbd48ac70a40060ada1 data/create/tags/block/passive_boiler_heaters.json +a615f3af71b117b4f5974a64a1c744ff072fba54 data/c/tags/block/relocation_not_supported.json +0604bc1712ca30d404c0c27b4c1469f729fdefd6 data/minecraft/tags/block/mineable/axe.json +ce83b2be6bbae03794f249386337ee5110241e57 data/minecraft/tags/block/mineable/pickaxe.json +9e6e50d40e3688ae681107e60ac5ff5fc22585f9 data/numismatics/loot_table/blocks/andesite_depositor.json +dc5c60bbbaf3a5d7bc1f9bc0c9377757dbd8de49 data/numismatics/loot_table/blocks/bank_terminal.json +266d9b0eb6fdecc4bcf6da465078d059009a3b54 data/numismatics/loot_table/blocks/blaze_banker.json +d048d04208faa63f0014d614d6026a66fe118c11 data/numismatics/loot_table/blocks/brass_depositor.json +6cb764824a2190dab6219b7502ec77f14c1a90e0 data/numismatics/loot_table/blocks/creative_vendor.json +3c36bec4bc30ac2bd5f0e0c881d6505b5cce35fe data/numismatics/loot_table/blocks/vendor.json +5d9d50abdbf4922335b7b2d1242efffd46d0d237 data/numismatics/tags/item/cards.json +21c85af57203e0c9029479d7a0fa39be536924ce data/numismatics/tags/item/coins.json +f49059171fc7b0d64bf27fec6fbe7270ba5de7b3 data/numismatics/tags/item/id_cards.json b3293e39ea5d4a1fdf65d014ec370b328ae36949 assets/numismatics/models/item/light_gray_id_card.json fa326874015c5f24f6a65390c31f96324eecc96d assets/numismatics/models/item/blue_card.json d36f0cc1a6b0873730d353bd84dda701d169265b data/numismatics/tags/items/internal/plates/iron_plates.json diff --git a/common/src/generated/resources/assets/numismatics/lang/en_ud.json b/common/src/generated/resources/assets/numismatics/lang/en_ud.json index 39c53488..b885fa3c 100644 --- a/common/src/generated/resources/assets/numismatics/lang/en_ud.json +++ b/common/src/generated/resources/assets/numismatics/lang/en_ud.json @@ -34,6 +34,10 @@ "block.numismatics.vendor.tooltip.trade_item": "ǝpɐɹ⟘ oʇ ɯǝʇI", "command.numismatics.arguments.enum.invalid": "%s :ǝɹɐ sǝnןɐʌ pıןɐΛ ˙,%s, ǝnןɐʌ ɯnuǝ pıןɐʌuI :ɹoɹɹƎ", "gui.numismatics.bank_terminal.balance": "¤%s '%s %s :ǝɔuɐןɐᗺ", + "gui.numismatics.checkout_screen.header": "ʇnoʞɔǝɥƆ", + "gui.numismatics.checkout_screen.pay_with_card": "pɹɐƆ ɥʇıʍ ʎɐԀ", + "gui.numismatics.checkout_screen.pay_with_coins": "suıoƆ ɥʇıʍ ʎɐԀ", + "gui.numismatics.checkout_screen.total": "¤%s '%s %s :ןɐʇo⟘ ɹǝpɹO", "gui.numismatics.trust_list": "ʇsıꞀ ʇsnɹ⟘", "gui.numismatics.vendor.count": ")x%s( ", "gui.numismatics.vendor.full": "ןןnɟ sı ɹopuǝΛ", @@ -106,5 +110,8 @@ "item.numismatics.yellow_id_card": "pɹɐƆ ᗡI ʍoןןǝʎ", "itemGroup.numismatics": "sɔıʇɐɯsıɯnN :ǝʇɐǝɹƆ", "numismatics.andesite_depositor.price": "ǝɔıɹԀ", + "numismatics.checkout.failure": "˙pǝbɹɐɥɔ uǝǝq ʇou ǝʌɐɥ noʎ ¡uoıʇɔɐsuɐɹʇ buıssǝɔoɹd ɹoɹɹƎ", + "numismatics.checkout.insufficient_funds": "¡spunɟ ʇuǝıɔıɟɟnsuI", + "numismatics.checkout.unauthorized": "pɹɐɔ ʇɐɥʇ ǝsn oʇ pǝzıɹoɥʇnɐ ʇou ǝɹ,noʎ", "numismatics.trust_list.configure": "ʇsıꞀ ʇsnɹ⟘ ǝɹnbıɟuoƆ" } \ No newline at end of file diff --git a/common/src/generated/resources/assets/numismatics/lang/en_us.json b/common/src/generated/resources/assets/numismatics/lang/en_us.json index ea382dd4..4c5d770b 100644 --- a/common/src/generated/resources/assets/numismatics/lang/en_us.json +++ b/common/src/generated/resources/assets/numismatics/lang/en_us.json @@ -34,6 +34,10 @@ "block.numismatics.vendor.tooltip.trade_item": "Item to Trade", "command.numismatics.arguments.enum.invalid": "Error: Invalid enum value '%s'. Valid values are: %s", "gui.numismatics.bank_terminal.balance": "Balance: %s %s, %s¤", + "gui.numismatics.checkout_screen.header": "Checkout", + "gui.numismatics.checkout_screen.pay_with_card": "Pay with Card", + "gui.numismatics.checkout_screen.pay_with_coins": "Pay with Coins", + "gui.numismatics.checkout_screen.total": "Order Total: %s %s, %s¤", "gui.numismatics.trust_list": "Trust List", "gui.numismatics.vendor.count": " (%sx)", "gui.numismatics.vendor.full": "Vendor is full", @@ -106,5 +110,8 @@ "item.numismatics.yellow_id_card": "Yellow ID Card", "itemGroup.numismatics": "Create: Numismatics", "numismatics.andesite_depositor.price": "Price", + "numismatics.checkout.failure": "Error processing transaction! You have not been charged.", + "numismatics.checkout.insufficient_funds": "Insufficient funds!", + "numismatics.checkout.unauthorized": "You're not authorized to use that card", "numismatics.trust_list.configure": "Configure Trust List" } \ No newline at end of file diff --git a/common/src/main/java/dev/ithundxr/createnumismatics/Numismatics.java b/common/src/main/java/dev/ithundxr/createnumismatics/Numismatics.java index 00b7a48a..5be8c126 100644 --- a/common/src/main/java/dev/ithundxr/createnumismatics/Numismatics.java +++ b/common/src/main/java/dev/ithundxr/createnumismatics/Numismatics.java @@ -15,6 +15,7 @@ import dev.ithundxr.createnumismatics.base.data.recipe.NumismaticsSequencedAssemblyRecipeGen; import dev.ithundxr.createnumismatics.base.data.recipe.NumismaticsStandardRecipeGen; import dev.ithundxr.createnumismatics.content.backend.GlobalBankManager; +import dev.ithundxr.createnumismatics.content.checkout.GlobalDeferredCheckoutOrderManager; import dev.ithundxr.createnumismatics.multiloader.Loader; import dev.ithundxr.createnumismatics.registry.NumismaticsAdvancements; import dev.ithundxr.createnumismatics.registry.NumismaticsCommands; @@ -38,6 +39,7 @@ public class Numismatics { public static final String VERSION = findVersion(); public static final Logger LOGGER = LoggerFactory.getLogger(NAME); public static final GlobalBankManager BANK = new GlobalBankManager(); + public static final GlobalDeferredCheckoutOrderManager DEFERRED_ORDERS = new GlobalDeferredCheckoutOrderManager(); private static final CreateRegistrate REGISTRATE = CreateRegistrate.create(MOD_ID); diff --git a/common/src/main/java/dev/ithundxr/createnumismatics/content/backend/Coin.java b/common/src/main/java/dev/ithundxr/createnumismatics/content/backend/Coin.java index 5ec7d65d..f05d25f6 100644 --- a/common/src/main/java/dev/ithundxr/createnumismatics/content/backend/Coin.java +++ b/common/src/main/java/dev/ithundxr/createnumismatics/content/backend/Coin.java @@ -125,4 +125,12 @@ public static Coin closest(int value) { } return closest; } + + public static final Coin[] byValueAscending = new Coin[] { + SPUR, BEVEL, SPROCKET, COG, CROWN, SUN + }; + + public static final Coin[] byValueDescending = new Coin[] { + SUN, CROWN, COG, SPROCKET, BEVEL, SPUR + }; } diff --git a/common/src/main/java/dev/ithundxr/createnumismatics/content/checkout/CheckoutMenu.java b/common/src/main/java/dev/ithundxr/createnumismatics/content/checkout/CheckoutMenu.java new file mode 100644 index 00000000..86e39a6c --- /dev/null +++ b/common/src/main/java/dev/ithundxr/createnumismatics/content/checkout/CheckoutMenu.java @@ -0,0 +1,173 @@ +package dev.ithundxr.createnumismatics.content.checkout; + +import com.simibubi.create.foundation.gui.menu.MenuBase; +import dev.ithundxr.createnumismatics.content.bank.CardItem; +import dev.ithundxr.createnumismatics.content.bank.CardSlot; +import dev.ithundxr.createnumismatics.registry.NumismaticsTags; +import dev.ithundxr.createnumismatics.util.Utils; +import net.minecraft.network.FriendlyByteBuf; +import net.minecraft.server.level.ServerPlayer; +import net.minecraft.world.Container; +import net.minecraft.world.ContainerHelper; +import net.minecraft.world.entity.player.Inventory; +import net.minecraft.world.entity.player.Player; +import net.minecraft.world.inventory.MenuType; +import net.minecraft.world.inventory.Slot; +import net.minecraft.world.item.ItemStack; +import org.jetbrains.annotations.NotNull; + +import java.util.ArrayList; +import java.util.List; +import java.util.UUID; +import java.util.function.Consumer; +import java.util.function.Function; + +public class CheckoutMenu extends MenuBase { + private CheckoutMenu.CardSwitchContainer cardSwitchContainer; + protected UUID currentCardUUID = Utils.emptyUUID; + + public CheckoutMenu(MenuType type, int id, Inventory inv, FriendlyByteBuf extraData) { + super(type, id, inv, extraData); + } + + public CheckoutMenu(MenuType type, int id, Inventory inv, DeferredCheckoutOrderMenuProvider contentHolder) { + super(type, id, inv, contentHolder); + } + + @Override + protected DeferredCheckoutOrderMenuProvider createOnClient(FriendlyByteBuf extraData) { + return DeferredCheckoutOrderMenuProvider.clientSide(extraData); + } + + @Override + protected void initAndReadInventory(DeferredCheckoutOrderMenuProvider contentHolder) { + } + + @Override + protected void addSlots() { + if (cardSwitchContainer == null) + cardSwitchContainer = new CheckoutMenu.CardSwitchContainer(this::slotsChanged, (id) -> { + currentCardUUID = id; + return true; + }); + + addSlot(new CardSlot.BoundCardSlot(cardSwitchContainer, 0, 148, 73)); + addPlayerSlots(40, 152); + } + + @Override + protected void addPlayerSlots(int x, int y) { + for (int hotbarSlot = 0; hotbarSlot < 9; ++hotbarSlot) { + this.addSlot(new LockableSlot(playerInventory, hotbarSlot, x + hotbarSlot * 18, y + 58, hotbarSlot == playerInventory.selected)); + } + for (int row = 0; row < 3; ++row) { + for (int col = 0; col < 9; ++col) { + int slot = col + row * 9 + 9; + this.addSlot(new LockableSlot(playerInventory, slot, x + col * 18, y + row * 18, slot == playerInventory.selected)); + } + } + } + + @Override + protected void saveData(DeferredCheckoutOrderMenuProvider contentHolder) { + } + + @Override + public void removed(Player playerIn) { + super.removed(playerIn); + if (playerIn instanceof ServerPlayer) { + clearContainer(player, cardSwitchContainer); + } + } + + @Override + public @NotNull ItemStack quickMoveStack(@NotNull Player player, int index) { // index is slot that was clicked + Slot clickedSlot = this.slots.get(index); + + if (!clickedSlot.hasItem()) + return ItemStack.EMPTY; + + if (NumismaticsTags.AllItemTags.CARDS.matches(clickedSlot.getItem())) { + if (index == 0) // They've clicked the card in the slot + moveItemStackTo(clickedSlot.getItem(), 1, player.getInventory().getContainerSize() + 1, false); + else // They've clicked a card in their inventory + moveItemStackTo(clickedSlot.getItem(), 0, 1, false); + } + + return ItemStack.EMPTY; + } + + private class CardSwitchContainer implements Container { + private final Consumer slotsChangedCallback; + private final Function uuidChangedCallback; // should return success + + @NotNull + protected final List stacks = new ArrayList<>(); + + public CardSwitchContainer(Consumer slotsChangedCallback, Function uuidChangedCallback) { + this.slotsChangedCallback = slotsChangedCallback; + this.uuidChangedCallback = uuidChangedCallback; + stacks.add(ItemStack.EMPTY); + } + + @Override + public int getContainerSize() { + return 1; + } + + protected ItemStack getStack() { + return stacks.get(0); + } + + @Override + public boolean isEmpty() { + return getStack().isEmpty(); + } + + @Override + public @NotNull ItemStack getItem(int slot) { + return getStack(); + } + + @Override + public @NotNull ItemStack removeItem(int slot, int amount) { + ItemStack stack = ContainerHelper.removeItem(this.stacks, 0, amount); + if (!stack.isEmpty()) { + this.slotsChangedCallback.accept(this); + } + return stack; + } + + @Override + public @NotNull ItemStack removeItemNoUpdate(int slot) { + return ContainerHelper.takeItem(this.stacks, 0); + } + + @Override + public void setItem(int slot, @NotNull ItemStack stack) { + this.stacks.set(0, stack); + if (CardItem.isBound(stack) && NumismaticsTags.AllItemTags.CARDS.matches(stack)) { + if (!this.uuidChangedCallback.apply(CardItem.get(stack))) { + // Non-existent account + stacks.set(0, CardItem.clear(stack)); + CheckoutMenu.this.clearContainer(CheckoutMenu.this.player, this); + } + } + this.slotsChangedCallback.accept(this); + } + + @Override + public void setChanged() { + } + + @Override + public boolean stillValid(@NotNull Player player) { + return true; + } + + @Override + public void clearContent() { + this.stacks.set(0, ItemStack.EMPTY); + } + } +} diff --git a/common/src/main/java/dev/ithundxr/createnumismatics/content/checkout/CheckoutPaymentMethod.java b/common/src/main/java/dev/ithundxr/createnumismatics/content/checkout/CheckoutPaymentMethod.java new file mode 100644 index 00000000..df878d4b --- /dev/null +++ b/common/src/main/java/dev/ithundxr/createnumismatics/content/checkout/CheckoutPaymentMethod.java @@ -0,0 +1,7 @@ +package dev.ithundxr.createnumismatics.content.checkout; + +public enum CheckoutPaymentMethod { + CANCEL_TRANSACTION, + CARD, + COINS; +} diff --git a/common/src/main/java/dev/ithundxr/createnumismatics/content/checkout/CheckoutScreen.java b/common/src/main/java/dev/ithundxr/createnumismatics/content/checkout/CheckoutScreen.java new file mode 100644 index 00000000..b07730d8 --- /dev/null +++ b/common/src/main/java/dev/ithundxr/createnumismatics/content/checkout/CheckoutScreen.java @@ -0,0 +1,165 @@ +package dev.ithundxr.createnumismatics.content.checkout; + +import com.google.common.collect.ImmutableList; +import com.simibubi.create.AllBlocks; +import com.simibubi.create.foundation.gui.AllGuiTextures; +import com.simibubi.create.foundation.gui.AllIcons; +import com.simibubi.create.foundation.gui.menu.AbstractSimiContainerScreen; +import com.simibubi.create.foundation.gui.widget.IconButton; +import dev.ithundxr.createnumismatics.Numismatics; +import dev.ithundxr.createnumismatics.content.backend.Coin; +import dev.ithundxr.createnumismatics.content.coins.CoinItem; +import dev.ithundxr.createnumismatics.registry.NumismaticsGuiTextures; +import dev.ithundxr.createnumismatics.registry.NumismaticsPackets; +import dev.ithundxr.createnumismatics.registry.packets.DeferredCheckoutResolutionPacket; +import dev.ithundxr.createnumismatics.util.TextUtils; +import dev.ithundxr.createnumismatics.util.Utils; +import net.createmod.catnip.data.Couple; +import net.createmod.catnip.gui.element.GuiGameElement; +import net.createmod.catnip.platform.CatnipServices; +import net.minecraft.Util; +import net.minecraft.client.gui.GuiGraphics; +import net.minecraft.client.gui.components.Button; +import net.minecraft.client.renderer.Rect2i; +import net.minecraft.network.chat.Component; +import net.minecraft.world.entity.player.Inventory; +import net.minecraft.world.item.ItemStack; +import org.jetbrains.annotations.NotNull; + +import java.util.Collections; +import java.util.List; + +public class CheckoutScreen extends AbstractSimiContainerScreen { + private final NumismaticsGuiTextures background = NumismaticsGuiTextures.CHECKOUT_SCREEN; + private final ItemStack renderedItem = AllBlocks.STOCK_TICKER.asStack(); + private List extraAreas = Collections.emptyList(); + + public CheckoutScreen(CheckoutMenu container, Inventory inv, Component title) { + super(container, inv, title); + } + + private Button payWithCoinsButton; + private Button payWithCardButton; + + @Override + protected void init() { + setWindowSize(background.width, background.height + 2 + AllGuiTextures.PLAYER_INVENTORY.getHeight()); + setWindowOffset(-20, 0); + super.init(); + + int x = leftPos; + int y = topPos; + + IconButton abortButton = new IconButton(x + background.width - 33, y + background.height - 24, AllIcons.I_MTD_CLOSE); + abortButton.withCallback(this::onCancelTransaction); + addRenderableWidget(abortButton); + + int btnW = 140; + int btnH = 20; + int gap = 6; + int btnX = x + (background.width - btnW - 10) / 2; + + payWithCoinsButton = Button.builder(Component.translatable("gui.numismatics.checkout_screen.pay_with_coins"), b -> onPayWithCoins()) + .pos(btnX, y + 45) + .size(btnW, btnH) + .build(); + updatePayWithCoinsButton(); + addRenderableWidget(payWithCoinsButton); + + payWithCardButton = Button.builder(Component.translatable("gui.numismatics.checkout_screen.pay_with_card"), b -> onPayWithCard()) + .pos(btnX, y + 45 + btnH + gap) + .size(btnW - 24, btnH) + .build(); + updatePayWithCardButton(); + addRenderableWidget(payWithCardButton); + + extraAreas = ImmutableList.of(new Rect2i(x + background.width, y + background.height - 64, 84, 74)); + } + + @Override + protected void containerTick() { + super.containerTick(); + updatePayWithCoinsButton(); + updatePayWithCardButton(); + } + + private void updatePayWithCoinsButton() { + if (minecraft == null || minecraft.player == null) + return; + + var inventory = minecraft.player.getInventory(); + int coinsOnPlayer = coinsInPlayerInventory(inventory); + payWithCoinsButton.active = menu.contentHolder.costInSpurs() <= coinsOnPlayer; + } + + private void updatePayWithCardButton() { + payWithCardButton.active = !menu.currentCardUUID.equals(Utils.emptyUUID); + } + + @Override + public void onClose() { + onCancelTransaction(); + } + + private void onPayWithCoins() { + onConfirmTransaction(CheckoutPaymentMethod.COINS); + } + + private void onPayWithCard() { + onConfirmTransaction(CheckoutPaymentMethod.CARD); + } + + private void onConfirmTransaction(CheckoutPaymentMethod method) { + Numismatics.LOGGER.info("Submitting resolution (APPROVED) of deferred order {}", this.menu.contentHolder.id()); + NumismaticsPackets.PACKETS.send(new DeferredCheckoutResolutionPacket(this.menu.contentHolder.id(), method, menu.currentCardUUID)); + super.onClose(); + } + + private void onCancelTransaction() { + Numismatics.LOGGER.info("Submitting resolution (DENIED) of deferred order {}", this.menu.contentHolder.id()); + NumismaticsPackets.PACKETS.send(new DeferredCheckoutResolutionPacket(this.menu.contentHolder.id(), CheckoutPaymentMethod.CANCEL_TRANSACTION, Utils.emptyUUID)); + super.onClose(); + } + + private int coinsInPlayerInventory(Inventory inv) { + int tally = 0; + for (int i = 0; i < inv.getContainerSize(); i++) { + var stack = inv.getItem(i); + if (stack.getItem() instanceof CoinItem ci) { + tally += ci.coin.toSpurs(stack.getCount()); + } + } + return tally; + } + + @Override + public List getExtraAreas() { + return extraAreas; + } + + @Override + protected void renderBg(@NotNull GuiGraphics graphics, float partialTick, int mouseX, int mouseY) { + int invX = getLeftOfCentered(AllGuiTextures.PLAYER_INVENTORY.getWidth()); + int invY = topPos + background.height + 2; + renderPlayerInventory(graphics, invX, invY); + + int x = leftPos; + int y = topPos; + + background.render(graphics, x, y); + + GuiGameElement.of(renderedItem).at(x + background.width + 6, y + background.height - 64, -200) + .scale(5) + .render(graphics); + + graphics.drawCenteredString(font, title, x + (background.width - 8) / 2, y + 3, 0xFFFFFF); + + Couple cogsAndSpurs = Coin.COG.convert(menu.contentHolder.costInSpurs()); + int cogs = cogsAndSpurs.getFirst(); + int spurs = cogsAndSpurs.getSecond(); + Component balanceLabel = Component.translatable("gui.numismatics.checkout_screen.total", + TextUtils.formatInt(cogs), Coin.COG.getName(cogs), spurs); + graphics.drawCenteredString(font, balanceLabel, x + (background.width - 8) / 2, y + 21, 0xFFFFFF); + } +} diff --git a/common/src/main/java/dev/ithundxr/createnumismatics/content/checkout/DeferredCheckoutOrder.java b/common/src/main/java/dev/ithundxr/createnumismatics/content/checkout/DeferredCheckoutOrder.java new file mode 100644 index 00000000..73eb5095 --- /dev/null +++ b/common/src/main/java/dev/ithundxr/createnumismatics/content/checkout/DeferredCheckoutOrder.java @@ -0,0 +1,323 @@ +package dev.ithundxr.createnumismatics.content.checkout; + +import com.simibubi.create.content.logistics.packager.InventorySummary; +import com.simibubi.create.content.logistics.stockTicker.PackageOrder; +import com.simibubi.create.content.logistics.stockTicker.StockTickerBlockEntity; +import com.simibubi.create.content.logistics.tableCloth.ShoppingListItem; +import dev.ithundxr.createnumismatics.Numismatics; +import dev.ithundxr.createnumismatics.content.backend.BankAccount; +import dev.ithundxr.createnumismatics.content.backend.Coin; +import dev.ithundxr.createnumismatics.content.coins.CoinItem; +import dev.ithundxr.createnumismatics.content.coins.DiscreteCoinBag; +import dev.ithundxr.createnumismatics.content.depositor.AbstractDepositorBlockEntity; +import dev.ithundxr.createnumismatics.mixin.MixinStockTickerBlockEntityReceivedPaymentsAccessor; +import dev.ithundxr.createnumismatics.multiloader.NumismaticsCheckoutUtilities; +import dev.ithundxr.createnumismatics.util.Utils; +import net.createmod.catnip.data.Couple; +import net.createmod.catnip.data.Iterate; +import net.minecraft.core.BlockPos; +import net.minecraft.core.Direction; +import net.minecraft.server.level.ServerPlayer; +import net.minecraft.world.InteractionHand; +import net.minecraft.world.entity.player.Inventory; +import net.minecraft.world.item.ItemStack; +import net.minecraft.world.level.Level; +import org.jetbrains.annotations.Nullable; + +import java.util.UUID; + +public class DeferredCheckoutOrder { + public UUID id; + + private boolean closed = false; + + private final int costInSpurs; + private final StockTickerBlockEntity stockTicker; + private final ServerPlayer player; + private final Level level; + private final InventorySummary itemCost; + private final PackageOrder deferredOrder; + private final String packageAddress; + + public DeferredCheckoutOrder(UUID orderId, ShoppingListItem.ShoppingList list, Level level, ServerPlayer player, StockTickerBlockEntity stockTicker, String packageAddress) { + Couple bakeEntries = list.bakeEntries(level, null); + InventorySummary paymentEntries = bakeEntries.getSecond(); + + // Determine cost of coin component of order + InventorySummary paymentWithoutCoins = new InventorySummary(); + int cost = 0; + for (var stack : paymentEntries.getStacksByCount()) { + if (stack.stack.getItem() instanceof CoinItem coinItem) { + cost += coinItem.coin.toSpurs(stack.count); + } else { + paymentWithoutCoins.add(stack); + } + } + costInSpurs = cost; + + this.id = orderId; + this.itemCost = paymentWithoutCoins; + this.deferredOrder = new PackageOrder(bakeEntries.getFirst().getStacksByCount()); + this.level = level; + this.player = player; + this.stockTicker = stockTicker; + this.packageAddress = packageAddress; + } + + public boolean isTransactionValid() { + if (closed) + return false; + + if (level.isClientSide) + return false; + + if (player.hasDisconnected()) + return false; + + if (stockTicker.isRemoved()) + return false; + + if (costInSpurs == 0) + return false; + + if (getDepositor() == null) + return false; + + return true; + } + + public boolean completePurchase(CheckoutPaymentMethod method, UUID purchasingAccountId) { + if (method == CheckoutPaymentMethod.CANCEL_TRANSACTION) + return false; + + BankAccount account = null; + if (method == CheckoutPaymentMethod.CARD) { + if (purchasingAccountId.equals(Utils.emptyUUID)) { + Numismatics.LOGGER.warn("Attempted to complete a card transaction {} with default bank account", id); + return false; + } + + account = Numismatics.BANK.getAccount(purchasingAccountId); + if (account == null) { + Numismatics.LOGGER.warn("Attempted to complete a card transaction {} with an non-empty, but invalid bank account {}", id, purchasingAccountId); + return false; + } + + if (!account.isAuthorized(player)) { + NumismaticsCheckoutUtilities.denyPurchase(level, player, "numismatics.checkout.unauthorized"); // Unauthorized + return false; + } + } + + if (!isTransactionValid()) { + Numismatics.LOGGER.warn("Attempted to complete an invalid transaction with UUID " + id); + return false; + } + + if (!NumismaticsCheckoutUtilities.checkOrderPreconditions(stockTicker, deferredOrder, level, player)) { + // checkOrderPreconditions displays the chat message + return false; + } + + if (method == CheckoutPaymentMethod.CARD && account.getBalance() < costInSpurs) { + NumismaticsCheckoutUtilities.denyPurchase(level, player, "numismatics.checkout.insufficient_funds"); + return false; + } + + if (method == CheckoutPaymentMethod.COINS && !playerHasEnoughCoinsInInventory(player.getInventory(), costInSpurs)) { + NumismaticsCheckoutUtilities.denyPurchase(level, player, "create.stock_keeper.too_broke"); + return false; + } + + /* + So below, we do the transaction two different ways: + 1) if its coins only, we can just skip a lot of steps, do the coin transaction, and submit the package order + directly to the system. Less points of failure, everyone wins. + 2) if we need to take coins and money, we'll let Create take the items first, then we'll take the coins. + *in theory* this is safe because: + a) if its a card transaction, the account balance is sufficient, and the player is authorized + b) if its a coin transaction, the player has enough coins in their inventory to cover the cost. + but technically there is a logical flow here that could result in *someone* getting short changed. + If this does somehow occur, the player will get some free items. If this becomes an issue, we can split the order up + into two orders, one for coins and one for items, then submit them either back to back, or merge them and submit. + */ + if (itemCost.isEmpty()) { + if (!tryDoCoinTransaction(method, account)) { + Numismatics.LOGGER.warn("Failed to do a numismatics transaction, even though all the preconditions passed!"); + NumismaticsCheckoutUtilities.denyPurchase(level, player, "numismatics.checkout.failure"); + return false; + } + + // If there's no item cost, we can skip a lot of the default create interaction. and just submit the order. + NumismaticsCheckoutUtilities.shopInteractionSubmitToNetwork(stockTicker, deferredOrder, player, level, packageAddress); + } else { + // There are item costs in the shopping list, so we must submit the order through the standard pipeline. + // This could potentially fail because of the stock keeper being too full, so we'll submit the order, let it + // take any items as payment. + var receivedPayments = ((MixinStockTickerBlockEntityReceivedPaymentsAccessor) stockTicker).getReceivedPayments(); + if (!NumismaticsCheckoutUtilities.finishShopInteractionStock(stockTicker, level, player, itemCost, deferredOrder, receivedPayments, packageAddress)) { + return false; + } + + if (!tryDoCoinTransaction(method, account)) { + Numismatics.LOGGER.warn("Failed to do a numismatics transaction, even though all the preconditions passed! " + + "Unfortunately, the stock order has already been placed and we cannot unwind it."); + NumismaticsCheckoutUtilities.denyPurchase(level, player, "numismatics.checkout.failure"); + return false; + } + } + return true; + } + + private boolean tryDoCoinTransaction(CheckoutPaymentMethod method, @Nullable BankAccount account) { + switch (method) { + case CARD -> { + if (account == null || !account.isAuthorized(player) || account.getBalance() < costInSpurs) { + return false; + } + account.deduct(costInSpurs); + } + case COINS -> { + if (!tryPayInSpurs(player.getInventory(), costInSpurs)) { + Numismatics.LOGGER.warn("Attempted to pay {} spurs from player {} ({}) inventory, but failed!", costInSpurs, player.getName(), player.getUUID()); + return false; + } + } + default -> throw new IllegalStateException("Unexpected value: " + method); + } + + depositCoinsToMerchant(); + return true; + } + + public boolean tryPayInSpurs(Inventory inventory, int spursToRemove) { + if (spursToRemove <= 0) { + return true; // nothing to pay + } + + // 1. Count available coins in the player's inventory + DiscreteCoinBag available = new DiscreteCoinBag(); + for (int i = 0; i < inventory.getContainerSize(); i++) { + ItemStack stack = inventory.getItem(i); + if (stack.getItem() instanceof CoinItem coinItem) { + available.add(coinItem.coin, stack.getCount()); + } + } + + if (available.getValue() < spursToRemove) { + return false; + } + + // 2. Plan which coins to remove (greedy from largest to smallest) + DiscreteCoinBag toRemove = new DiscreteCoinBag(); + int remaining = spursToRemove; + for (Coin coin : Coin.byValueDescending) { + if (remaining <= 0) + break; + + int canUse = Math.min(available.getDiscrete(coin), remaining / coin.value); + if (canUse > 0) { + toRemove.add(coin, canUse); + remaining -= coin.toSpurs(canUse); + } + } + + // 3. If we still have remaining spurs to cover, try to break one larger coin + // Find the smallest denomination that is strictly larger than 'remaining' and still available + DiscreteCoinBag changeToAdd = new DiscreteCoinBag(); + if (remaining > 0) { + // Search ascending for the smallest coin whose value >= remaining and still available + Coin breaker = null; + for (Coin coin : Coin.byValueAscending) { + int availableCount = available.getDiscrete(coin) - toRemove.getDiscrete(coin); + if (availableCount > 0 && coin.value >= remaining) { + breaker = coin; + break; + } + } + + if (breaker == null) { + // Can't cover the remaining with a single larger coin; payment impossible + return false; + } + + // Use one breaker coin + toRemove.add(breaker, 1); + changeToAdd = DiscreteCoinBag.ofChange(remaining, breaker); + } + + // 4. Apply Changes + if (!CoinItem.extract(player, InteractionHand.MAIN_HAND, toRemove.asMap(), true, false)) { + return false; + } + if (!CoinItem.extract(player, InteractionHand.MAIN_HAND, toRemove.asMap(), false, false)) { + return false; + } + + // 5. Return change to the player + for (Coin coin : Coin.values()) { + int count = changeToAdd.getDiscrete(coin); + if (count <= 0) + continue; + + // Split into max stack sizes as needed + int max = coin.asStack().getMaxStackSize(); + int left = count; + while (left > 0) { + int n = Math.min(max, left); + ItemStack change = coin.asStack(n); + player.getInventory().placeItemBackInInventory(change); + left -= n; + } + } + + return true; + } + + private boolean playerHasEnoughCoinsInInventory(Inventory inv, int target) { + var spursInInventory = 0; + for (int slot = 0; slot < inv.getContainerSize(); slot++) { + var stack = inv.getItem(slot); + if (stack.getItem() instanceof CoinItem coin) { + spursInInventory += coin.coin.toSpurs(stack.getCount()); + if (spursInInventory >= target) + return true; + } + } + return false; + } + + private AbstractDepositorBlockEntity getDepositor() { + for (Direction side : Iterate.horizontalDirections) { + BlockPos pos = stockTicker.getBlockPos().relative(side); + var e = level.getBlockEntity(pos); + if (e instanceof AbstractDepositorBlockEntity depositor) + return depositor; + } + return null; + } + + private void depositCoinsToMerchant() { + var depositor = getDepositor(); + if (depositor == null) + return; + + var account = Numismatics.BANK.getAccount(depositor.getDepositAccount()); + if (account != null) { + account.deposit(costInSpurs); + } else { + var coins = DiscreteCoinBag.ofGreedy(costInSpurs); + for (var c : Coin.values()) { + depositor.addCoin(c, coins.getDiscrete(c)); + } + } + } + + public void close() { + closed = true; + } + + public DeferredCheckoutOrderMenuProvider createMenuProvider() { + return new DeferredCheckoutOrderMenuProvider(id, costInSpurs); + } +} diff --git a/common/src/main/java/dev/ithundxr/createnumismatics/content/checkout/DeferredCheckoutOrderMenuProvider.java b/common/src/main/java/dev/ithundxr/createnumismatics/content/checkout/DeferredCheckoutOrderMenuProvider.java new file mode 100644 index 00000000..7f8f27d7 --- /dev/null +++ b/common/src/main/java/dev/ithundxr/createnumismatics/content/checkout/DeferredCheckoutOrderMenuProvider.java @@ -0,0 +1,34 @@ +package dev.ithundxr.createnumismatics.content.checkout; + +import dev.ithundxr.createnumismatics.registry.NumismaticsMenuTypes; +import net.minecraft.network.FriendlyByteBuf; +import net.minecraft.network.chat.Component; +import net.minecraft.world.MenuProvider; +import net.minecraft.world.entity.player.Inventory; +import net.minecraft.world.entity.player.Player; +import net.minecraft.world.inventory.AbstractContainerMenu; +import org.jetbrains.annotations.Nullable; + +import java.util.UUID; + +public record DeferredCheckoutOrderMenuProvider(UUID id, int costInSpurs) implements MenuProvider { + + @Override + public Component getDisplayName() { + return Component.translatable("gui.numismatics.checkout_screen.header"); + } + + @Override + public @Nullable AbstractContainerMenu createMenu(int i, Inventory inventory, Player player) { + return new CheckoutMenu(NumismaticsMenuTypes.CHECKOUT.get(), i, inventory, this); + } + + public static DeferredCheckoutOrderMenuProvider clientSide(FriendlyByteBuf buf) { + return new DeferredCheckoutOrderMenuProvider(buf.readUUID(), buf.readVarInt()); + } + + public void sendToMenu(FriendlyByteBuf buf) { + buf.writeUUID(this.id); + buf.writeVarInt(this.costInSpurs); + } +} diff --git a/common/src/main/java/dev/ithundxr/createnumismatics/content/checkout/GlobalDeferredCheckoutOrderManager.java b/common/src/main/java/dev/ithundxr/createnumismatics/content/checkout/GlobalDeferredCheckoutOrderManager.java new file mode 100644 index 00000000..275f0c72 --- /dev/null +++ b/common/src/main/java/dev/ithundxr/createnumismatics/content/checkout/GlobalDeferredCheckoutOrderManager.java @@ -0,0 +1,50 @@ +package dev.ithundxr.createnumismatics.content.checkout; + +import com.simibubi.create.content.logistics.stockTicker.StockTickerBlockEntity; +import com.simibubi.create.content.logistics.tableCloth.ShoppingListItem; +import dev.ithundxr.createnumismatics.Numismatics; +import dev.ithundxr.createnumismatics.util.Utils; +import net.minecraft.server.level.ServerPlayer; +import net.minecraft.world.level.Level; + +import java.util.HashMap; +import java.util.Map; +import java.util.UUID; + +public class GlobalDeferredCheckoutOrderManager { + + private final Map deferredOrders = new HashMap<>(); + + private void warnIfClient() { + if (Thread.currentThread().getName().equals("Render thread")) { + long start = System.currentTimeMillis(); + Numismatics.LOGGER.error("Deferred Checkout manager should not be accessed on the client"); // set breakpoint here when developing + if (Utils.isDevEnv()) { + long end = System.currentTimeMillis(); + if (end - start < 50) { // crash if breakpoint wasn't set + throw new RuntimeException("Illegal checkout performed on client, please set a breakpoint above"); + } + } else { + Numismatics.LOGGER.error("Stacktrace: ", new RuntimeException("Illegal checkout access performed on client")); + } + } + } + + public DeferredCheckoutOrder deferOrder(ShoppingListItem.ShoppingList list, Level level, ServerPlayer player, StockTickerBlockEntity stockTicker, String packageAddress) { + warnIfClient(); + var order = new DeferredCheckoutOrder(UUID.randomUUID(), list, level, player, stockTicker, packageAddress); + deferredOrders.put(order.id, order); + return order; + } + + public DeferredCheckoutOrder getDeferredOrder(UUID id) { + warnIfClient(); + return deferredOrders.get(id); + } + + public void voidOrder(DeferredCheckoutOrder order) { + warnIfClient(); + order.close(); + deferredOrders.remove(order.id); + } +} diff --git a/common/src/main/java/dev/ithundxr/createnumismatics/content/checkout/LockableSlot.java b/common/src/main/java/dev/ithundxr/createnumismatics/content/checkout/LockableSlot.java new file mode 100644 index 00000000..eca8f120 --- /dev/null +++ b/common/src/main/java/dev/ithundxr/createnumismatics/content/checkout/LockableSlot.java @@ -0,0 +1,36 @@ +package dev.ithundxr.createnumismatics.content.checkout; + +import net.minecraft.world.Container; +import net.minecraft.world.entity.player.Player; +import net.minecraft.world.inventory.Slot; +import net.minecraft.world.item.ItemStack; + +public class LockableSlot extends Slot { + + private boolean locked; + + public LockableSlot(Container inventory, int invSlot, int x, int y, boolean initiallyLocked) { + super(inventory, invSlot, x, y); + locked = initiallyLocked; + } + + public void setLocked(boolean locked) { + this.locked = locked; + } + + @Override + public boolean mayPlace(ItemStack stack) { + if (locked) + return false; + else + return super.mayPlace(stack); + } + + @Override + public boolean mayPickup(Player player) { + if (locked) + return false; + else + return super.mayPickup(player); + } +} \ No newline at end of file diff --git a/common/src/main/java/dev/ithundxr/createnumismatics/content/coins/DiscreteCoinBag.java b/common/src/main/java/dev/ithundxr/createnumismatics/content/coins/DiscreteCoinBag.java index 44f2b5c3..6a172159 100644 --- a/common/src/main/java/dev/ithundxr/createnumismatics/content/coins/DiscreteCoinBag.java +++ b/common/src/main/java/dev/ithundxr/createnumismatics/content/coins/DiscreteCoinBag.java @@ -76,6 +76,8 @@ public ItemStack asStack(Coin coin) { return NumismaticsItems.getCoin(coin).asStack(amt); } + public Map asMap() { return new HashMap<>(this.coins); } + @Override public int getValue() { return value; @@ -116,6 +118,25 @@ public static DiscreteCoinBag of(Map coins) { return new DiscreteCoinBag(coins); } + public static DiscreteCoinBag ofGreedy(int totalSpurValue) { + var bag = new DiscreteCoinBag(); + int spurs = totalSpurValue; + for (var coin : Coin.byValueDescending) + { + var tuple = coin.convert(spurs); + if (tuple.getFirst() != 0) + bag.add(coin, tuple.getFirst()); + spurs = tuple.getSecond(); + } + return bag; + } + + public static DiscreteCoinBag ofChange(int costInSpurs, Coin coinToBreak) + { + return DiscreteCoinBag.ofGreedy(coinToBreak.value - costInSpurs); + } + + public static DiscreteCoinBag of() { return new DiscreteCoinBag(); } diff --git a/common/src/main/java/dev/ithundxr/createnumismatics/mixin/MixinStockTickerBlockEntityReceivedPaymentsAccessor.java b/common/src/main/java/dev/ithundxr/createnumismatics/mixin/MixinStockTickerBlockEntityReceivedPaymentsAccessor.java new file mode 100644 index 00000000..a7b12269 --- /dev/null +++ b/common/src/main/java/dev/ithundxr/createnumismatics/mixin/MixinStockTickerBlockEntityReceivedPaymentsAccessor.java @@ -0,0 +1,12 @@ +package dev.ithundxr.createnumismatics.mixin; + +import com.simibubi.create.content.logistics.stockTicker.StockTickerBlockEntity; +import com.simibubi.create.foundation.item.SmartInventory; +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.gen.Accessor; + +@Mixin(StockTickerBlockEntity.class) +public interface MixinStockTickerBlockEntityReceivedPaymentsAccessor { + @Accessor + SmartInventory getReceivedPayments(); +} diff --git a/common/src/main/java/dev/ithundxr/createnumismatics/mixin/MixinStockTickerInteractionHandler.java b/common/src/main/java/dev/ithundxr/createnumismatics/mixin/MixinStockTickerInteractionHandler.java new file mode 100644 index 00000000..4851420e --- /dev/null +++ b/common/src/main/java/dev/ithundxr/createnumismatics/mixin/MixinStockTickerInteractionHandler.java @@ -0,0 +1,50 @@ +package dev.ithundxr.createnumismatics.mixin; + +import com.llamalad7.mixinextras.sugar.Local; +import com.simibubi.create.content.logistics.stockTicker.StockTickerBlockEntity; +import com.simibubi.create.content.logistics.stockTicker.StockTickerInteractionHandler; +import com.simibubi.create.content.logistics.tableCloth.ShoppingListItem; +import dev.ithundxr.createnumismatics.Numismatics; +import dev.ithundxr.createnumismatics.content.checkout.DeferredCheckoutOrderMenuProvider; +import dev.ithundxr.createnumismatics.util.Utils; +import net.minecraft.core.BlockPos; +import net.minecraft.server.level.ServerPlayer; +import net.minecraft.world.entity.player.Player; +import net.minecraft.world.item.ItemStack; +import net.minecraft.world.level.Level; +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.injection.At; +import org.spongepowered.asm.mixin.injection.Inject; +import org.spongepowered.asm.mixin.injection.callback.CallbackInfo; + +@Mixin(StockTickerInteractionHandler.class) +public class MixinStockTickerInteractionHandler { + @Inject(method = "interactWithShop", at = @At(value = "INVOKE", target = "Lcom/simibubi/create/content/logistics/stockTicker/StockTickerBlockEntity;getAccurateSummary()Lcom/simibubi/create/content/logistics/packager/InventorySummary;"), cancellable = true) + private static void interactWithShop( + Player player, + Level level, + BlockPos targetPos, + ItemStack mainHandItem, + CallbackInfo ci, + @Local ShoppingListItem.ShoppingList shoppingList, + @Local StockTickerBlockEntity tickerBE + ) { + + // Build the deferred order and check to see if all preconditions are being met + var address = ShoppingListItem.getAddress(mainHandItem); + var deferredOrder = Numismatics.DEFERRED_ORDERS.deferOrder(shoppingList, level, (ServerPlayer) player, tickerBE, address); + if (!deferredOrder.isTransactionValid()) { + // Transaction isn't valid, backout! + Numismatics.DEFERRED_ORDERS.voidOrder(deferredOrder); + return; + } + + // At this point, we've determined this is a numismatics transaction, + // and we will *not* be allowing the standard trade to complete now. We must defer it + ci.cancel(); + + var deferredOrderModel = deferredOrder.createMenuProvider(); + Utils.openScreen((ServerPlayer) player, deferredOrderModel, deferredOrderModel::sendToMenu); + } + +} diff --git a/common/src/main/java/dev/ithundxr/createnumismatics/multiloader/NumismaticsCheckoutUtilities.java b/common/src/main/java/dev/ithundxr/createnumismatics/multiloader/NumismaticsCheckoutUtilities.java new file mode 100644 index 00000000..f21c9fda --- /dev/null +++ b/common/src/main/java/dev/ithundxr/createnumismatics/multiloader/NumismaticsCheckoutUtilities.java @@ -0,0 +1,39 @@ +package dev.ithundxr.createnumismatics.multiloader; + +import com.simibubi.create.content.logistics.packager.InventorySummary; +import com.simibubi.create.content.logistics.stockTicker.PackageOrder; +import com.simibubi.create.content.logistics.stockTicker.StockTickerBlockEntity; +import com.simibubi.create.foundation.item.SmartInventory; +import dev.architectury.injectables.annotations.ExpectPlatform; +import net.minecraft.world.entity.player.Player; +import net.minecraft.world.level.Level; + +public class NumismaticsCheckoutUtilities { + + @ExpectPlatform + public static void shopInteractionSubmitToNetwork(StockTickerBlockEntity tickerBE, PackageOrder order, Player player, Level level, String packageAddress) { + throw new AssertionError(); + } + + @ExpectPlatform + public static boolean checkOrderPreconditions(StockTickerBlockEntity tickerBE, PackageOrder order, Level level, Player player) { + throw new AssertionError(); + } + + @ExpectPlatform + public static boolean finishShopInteractionStock( + StockTickerBlockEntity tickerBE, + Level level, + Player player, + InventorySummary paymentEntries, + PackageOrder order, + SmartInventory receivedPayments, + String packageAddress) { + throw new AssertionError(); + } + + @ExpectPlatform + public static void denyPurchase(Level level, Player player, String langKey) { + throw new AssertionError(); + } +} diff --git a/common/src/main/java/dev/ithundxr/createnumismatics/registry/NumismaticsGuiTextures.java b/common/src/main/java/dev/ithundxr/createnumismatics/registry/NumismaticsGuiTextures.java index efac2c4c..3a0e7507 100644 --- a/common/src/main/java/dev/ithundxr/createnumismatics/registry/NumismaticsGuiTextures.java +++ b/common/src/main/java/dev/ithundxr/createnumismatics/registry/NumismaticsGuiTextures.java @@ -22,6 +22,7 @@ public enum NumismaticsGuiTextures implements ScreenElement { BLAZE_BANKER("blaze_banker",200, 110), VENDOR("vendor", 236, 145), CREATIVE_VENDOR("creative_vendor", 236, 145), + CHECKOUT_SCREEN("checkout_screen", 200, 132), ; public static final int FONT_COLOR = 0x575F7A; diff --git a/common/src/main/java/dev/ithundxr/createnumismatics/registry/NumismaticsMenuTypes.java b/common/src/main/java/dev/ithundxr/createnumismatics/registry/NumismaticsMenuTypes.java index d14999ac..f14dec40 100644 --- a/common/src/main/java/dev/ithundxr/createnumismatics/registry/NumismaticsMenuTypes.java +++ b/common/src/main/java/dev/ithundxr/createnumismatics/registry/NumismaticsMenuTypes.java @@ -9,6 +9,8 @@ import dev.ithundxr.createnumismatics.content.backend.trust_list.TrustListScreen; import dev.ithundxr.createnumismatics.content.bank.blaze_banker.BlazeBankerMenu; import dev.ithundxr.createnumismatics.content.bank.blaze_banker.BlazeBankerScreen; +import dev.ithundxr.createnumismatics.content.checkout.CheckoutMenu; +import dev.ithundxr.createnumismatics.content.checkout.CheckoutScreen; import dev.ithundxr.createnumismatics.content.depositor.AndesiteDepositorMenu; import dev.ithundxr.createnumismatics.content.depositor.AndesiteDepositorScreen; import dev.ithundxr.createnumismatics.content.bank.BankMenu; @@ -60,6 +62,12 @@ public class NumismaticsMenuTypes { () -> VendorScreen::new ); + public static final MenuEntry CHECKOUT = register( + "checkout", + CheckoutMenu::new, + () -> CheckoutScreen::new + ); + private static > MenuEntry register( String name, MenuBuilder.ForgeMenuFactory factory, NonNullSupplier> screenFactory) { return REGISTRATE diff --git a/common/src/main/java/dev/ithundxr/createnumismatics/registry/NumismaticsPackets.java b/common/src/main/java/dev/ithundxr/createnumismatics/registry/NumismaticsPackets.java index 0c6150fe..c34af94e 100644 --- a/common/src/main/java/dev/ithundxr/createnumismatics/registry/NumismaticsPackets.java +++ b/common/src/main/java/dev/ithundxr/createnumismatics/registry/NumismaticsPackets.java @@ -14,6 +14,7 @@ public class NumismaticsPackets { .c2s(AndesiteDepositorConfigurationPacket.class, AndesiteDepositorConfigurationPacket::new) .c2s(OpenTrustListPacket.class, OpenTrustListPacket::new) .c2s(VendorConfigurationPacket.class, VendorConfigurationPacket::new) + .c2s(DeferredCheckoutResolutionPacket.class, DeferredCheckoutResolutionPacket::new) .s2c(BankAccountLabelPacket.class, BankAccountLabelPacket::new) .s2c(VarIntContainerSetDataPacket.class, VarIntContainerSetDataPacket::new) diff --git a/common/src/main/java/dev/ithundxr/createnumismatics/registry/packets/DeferredCheckoutResolutionPacket.java b/common/src/main/java/dev/ithundxr/createnumismatics/registry/packets/DeferredCheckoutResolutionPacket.java new file mode 100644 index 00000000..404fa27e --- /dev/null +++ b/common/src/main/java/dev/ithundxr/createnumismatics/registry/packets/DeferredCheckoutResolutionPacket.java @@ -0,0 +1,55 @@ +package dev.ithundxr.createnumismatics.registry.packets; + +import dev.ithundxr.createnumismatics.Numismatics; +import dev.ithundxr.createnumismatics.content.checkout.CheckoutPaymentMethod; +import dev.ithundxr.createnumismatics.multiloader.C2SPacket; +import dev.ithundxr.createnumismatics.registry.NumismaticsPackets; +import io.netty.buffer.ByteBuf; +import net.minecraft.core.UUIDUtil; +import net.minecraft.network.FriendlyByteBuf; +import net.minecraft.server.level.ServerPlayer; +import org.jetbrains.annotations.NotNull; + +import java.util.UUID; + +public class DeferredCheckoutResolutionPacket implements C2SPacket { + + @NotNull + private final UUID transactionId; + + @NotNull + private final CheckoutPaymentMethod method; + + @NotNull + private final UUID bankAccount; + + public DeferredCheckoutResolutionPacket(FriendlyByteBuf buf) { + this.transactionId = buf.readUUID(); + this.method = buf.readEnum(CheckoutPaymentMethod.class); + this.bankAccount = buf.readUUID(); + } + + public DeferredCheckoutResolutionPacket(UUID transactionId, CheckoutPaymentMethod method, UUID bankAccount) { + this.transactionId = transactionId; + this.method = method; + this.bankAccount = bankAccount; + } + + @Override + public void write(FriendlyByteBuf buffer) { + buffer.writeUUID(transactionId); + buffer.writeEnum(method); + buffer.writeUUID(bankAccount); + } + + @Override + public void handle(ServerPlayer player) { + Numismatics.LOGGER.info("Received checkout packet! Transaction: {}; Method: {}; BankAccount: {}", transactionId, method, bankAccount); + var order = Numismatics.DEFERRED_ORDERS.getDeferredOrder(transactionId); + if (order == null) + return; + + order.completePurchase(method, bankAccount); + Numismatics.DEFERRED_ORDERS.voidOrder(order); + } +} diff --git a/common/src/main/java/dev/ithundxr/createnumismatics/util/Utils.java b/common/src/main/java/dev/ithundxr/createnumismatics/util/Utils.java index d2189fef..8435d896 100644 --- a/common/src/main/java/dev/ithundxr/createnumismatics/util/Utils.java +++ b/common/src/main/java/dev/ithundxr/createnumismatics/util/Utils.java @@ -8,6 +8,7 @@ import net.minecraft.world.entity.player.Player; import org.apache.commons.lang3.mutable.MutableObject; +import java.util.UUID; import java.util.function.Consumer; import java.util.function.Predicate; @@ -27,4 +28,6 @@ public static boolean testClientPlayerOrElse(Predicate predicate, boolea public static void openScreen(ServerPlayer player, MenuProvider factory, Consumer extraDataWriter) { throw new AssertionError(); } + + public static final UUID emptyUUID = new UUID(0L, 0L); } diff --git a/common/src/main/resources/assets/numismatics/lang/default/interface.json b/common/src/main/resources/assets/numismatics/lang/default/interface.json index 9cc2de35..6be931cf 100644 --- a/common/src/main/resources/assets/numismatics/lang/default/interface.json +++ b/common/src/main/resources/assets/numismatics/lang/default/interface.json @@ -27,6 +27,14 @@ "gui.numismatics.vendor.full": "Vendor is full", "gui.numismatics.vendor.full.named": "Vendor is full, contact %s to empty it", "gui.numismatics.vendor.no_item_in_hand": "Hold the stack of items you want to sell", + "gui.numismatics.checkout_screen.total": "Order Total: %s %s, %s¤", + "gui.numismatics.checkout_screen.header": "Checkout", + "gui.numismatics.checkout_screen.pay_with_coins": "Pay with Coins", + "gui.numismatics.checkout_screen.pay_with_card": "Pay with Card", + + "numismatics.checkout.unauthorized": "You're not authorized to use that card", + "numismatics.checkout.insufficient_funds": "Insufficient funds!", + "numismatics.checkout.failure": "Error processing transaction! You have not been charged.", "block.numismatics.trusted_block.attempt_break": "Hold shift to break this block" } \ No newline at end of file diff --git a/common/src/main/resources/assets/numismatics/textures/gui/checkout_screen.png b/common/src/main/resources/assets/numismatics/textures/gui/checkout_screen.png new file mode 100644 index 00000000..fc0accb6 Binary files /dev/null and b/common/src/main/resources/assets/numismatics/textures/gui/checkout_screen.png differ diff --git a/common/src/main/resources/numismatics-common.mixins.json b/common/src/main/resources/numismatics-common.mixins.json index 143d3939..50480d97 100644 --- a/common/src/main/resources/numismatics-common.mixins.json +++ b/common/src/main/resources/numismatics-common.mixins.json @@ -11,6 +11,8 @@ "AccessorSimpleContainer", "MixinAbstractContainerMenu", "MixinServerPlayer", + "MixinStockTickerInteractionHandler", + "MixinStockTickerBlockEntityReceivedPaymentsAccessor", "compat.carryon.MixinPickupHandler" ], "injectors": { diff --git a/fabric/src/main/java/dev/ithundxr/createnumismatics/multiloader/fabric/NumismaticsCheckoutUtilitiesImpl.java b/fabric/src/main/java/dev/ithundxr/createnumismatics/multiloader/fabric/NumismaticsCheckoutUtilitiesImpl.java new file mode 100644 index 00000000..c791fd7d --- /dev/null +++ b/fabric/src/main/java/dev/ithundxr/createnumismatics/multiloader/fabric/NumismaticsCheckoutUtilitiesImpl.java @@ -0,0 +1,135 @@ +package dev.ithundxr.createnumismatics.multiloader.fabric; + +import com.simibubi.create.AllSoundEvents; +import com.simibubi.create.content.logistics.BigItemStack; +import com.simibubi.create.content.logistics.packager.InventorySummary; +import com.simibubi.create.content.logistics.packagerLink.LogisticallyLinkedBehaviour; +import com.simibubi.create.content.logistics.stockTicker.PackageOrder; +import com.simibubi.create.content.logistics.stockTicker.StockTickerBlockEntity; +import com.simibubi.create.content.logistics.tableCloth.ShoppingListItem; +import com.simibubi.create.foundation.item.SmartInventory; +import dev.ithundxr.createnumismatics.multiloader.NumismaticsCheckoutUtilities; +import io.github.fabricators_of_create.porting_lib.transfer.TransferUtil; +import net.createmod.catnip.data.Iterate; +import net.fabricmc.fabric.api.transfer.v1.item.ItemVariant; +import net.fabricmc.fabric.api.transfer.v1.storage.StorageView; +import net.minecraft.ChatFormatting; +import net.minecraft.network.chat.Component; +import net.minecraft.util.Mth; +import net.minecraft.world.InteractionHand; +import net.minecraft.world.entity.player.Player; +import net.minecraft.world.item.ItemStack; +import net.minecraft.world.level.Level; + +import java.util.ArrayList; +import java.util.List; + +/* + This class mostly contains code extracted from + com.simibubi.create.content.logistics.stockTicker.StockTickerInteractionHandler.interactWithShop() + in order to facilitate deferring the order placement. We need to call different parts of that one function at different times. + + As such, it has been isolated to its own class. If the interactWithShop() function changes, most of the impact + will be to this file, and the mixin which kicks off the whole deferred checkout process + */ +public class NumismaticsCheckoutUtilitiesImpl extends NumismaticsCheckoutUtilities { + + public static void shopInteractionSubmitToNetwork(StockTickerBlockEntity tickerBE, PackageOrder order, Player player, Level level, String packageAddress) { + tickerBE.broadcastPackageRequest(LogisticallyLinkedBehaviour.RequestType.PLAYER, order, null, packageAddress); + if (player.getItemInHand(InteractionHand.MAIN_HAND).getItem() instanceof ShoppingListItem) + player.setItemInHand(InteractionHand.MAIN_HAND, ItemStack.EMPTY); + if (!order.isEmpty()) + AllSoundEvents.STOCK_TICKER_TRADE.playOnServer(level, tickerBE.getBlockPos()); + } + + public static boolean checkOrderPreconditions(StockTickerBlockEntity tickerBE, PackageOrder order, Level level, Player player) { + // Must be up-to-date + tickerBE.getAccurateSummary(); + + // Check stock levels + InventorySummary recentSummary = tickerBE.getRecentSummary(); + for (BigItemStack entry : order.stacks()) { + if (recentSummary.getCountOf(entry.stack) >= entry.count) + continue; + + denyPurchase(level, player, "create.stock_keeper.stock_level_too_low"); + return false; + } + return true; + } + + /* + The "Stock" in the name of this function refers to "Standard" as in this is what we'll call to invoke the standard + process of placing an order if the order also contains items, instead of just coins. + */ + public static boolean finishShopInteractionStock( + StockTickerBlockEntity tickerBE, + Level level, + Player player, + InventorySummary paymentEntries, + PackageOrder order, + SmartInventory receivedPayments, + String packageAddress) { + if (!checkOrderPreconditions(tickerBE, order, level, player)) + return false; + + // Check space in stock ticker + int occupiedSlots = 0; + for (BigItemStack entry : paymentEntries.getStacksByCount()) + occupiedSlots += Mth.ceil(entry.count / (float) entry.stack.getMaxStackSize()); + for (StorageView slot : tickerBE.getReceivedPaymentsHandler()) { + if (slot.isResourceBlank()) { + occupiedSlots--; + } + } + + if (occupiedSlots > 0) { + denyPurchase(level, player, "create.stock_keeper.cash_register_full"); + return false; + } + + // Transfer payment to stock ticker + for (boolean simulate : Iterate.trueAndFalse) { + InventorySummary tally = paymentEntries.copy(); + List toTransfer = new ArrayList<>(); + + for (int i = 0; i < player.getInventory().items.size(); i++) { + ItemStack item = player.getInventory() + .getItem(i); + if (item.isEmpty()) + continue; + int countOf = tally.getCountOf(item); + if (countOf == 0) + continue; + int toRemove = Math.min(item.getCount(), countOf); + tally.add(item, -toRemove); + + if (simulate) + continue; + + int newStackSize = item.getCount() - toRemove; + player.getInventory() + .setItem(i, newStackSize == 0 ? ItemStack.EMPTY : item.copyWithCount(newStackSize)); + toTransfer.add(item.copyWithCount(toRemove)); + } + + if (simulate && tally.getTotalCount() != 0) { + denyPurchase(level, player, "create.stock_keeper.too_broke"); + return false; + } + + if (simulate) + continue; + + toTransfer.forEach(s -> TransferUtil.insertItem(tickerBE.getReceivedPaymentsHandler(), s)); + } + + shopInteractionSubmitToNetwork(tickerBE, order, player, level, packageAddress); + return true; + } + + public static void denyPurchase(Level level, Player player, String langKey) { + AllSoundEvents.DENY.playOnServer(level, player.blockPosition()); + player.displayClientMessage(Component.translatable(langKey).withStyle(ChatFormatting.RED), true); + } +} diff --git a/forge/src/main/java/dev/ithundxr/createnumismatics/multiloader/forge/NumismaticsCheckoutUtilitiesImpl.java b/forge/src/main/java/dev/ithundxr/createnumismatics/multiloader/forge/NumismaticsCheckoutUtilitiesImpl.java new file mode 100644 index 00000000..1e6a95e7 --- /dev/null +++ b/forge/src/main/java/dev/ithundxr/createnumismatics/multiloader/forge/NumismaticsCheckoutUtilitiesImpl.java @@ -0,0 +1,132 @@ +package dev.ithundxr.createnumismatics.multiloader.forge; + +import com.simibubi.create.AllSoundEvents; +import com.simibubi.create.content.logistics.BigItemStack; +import com.simibubi.create.content.logistics.packager.InventorySummary; +import com.simibubi.create.content.logistics.packagerLink.LogisticallyLinkedBehaviour; +import com.simibubi.create.content.logistics.stockTicker.PackageOrder; +import com.simibubi.create.content.logistics.stockTicker.StockTickerBlockEntity; +import com.simibubi.create.content.logistics.tableCloth.ShoppingListItem; +import com.simibubi.create.foundation.item.SmartInventory; +import dev.ithundxr.createnumismatics.multiloader.NumismaticsCheckoutUtilities; +import net.createmod.catnip.data.Iterate; +import net.minecraft.ChatFormatting; +import net.minecraft.network.chat.Component; +import net.minecraft.util.Mth; +import net.minecraft.world.InteractionHand; +import net.minecraft.world.entity.player.Player; +import net.minecraft.world.item.ItemStack; +import net.minecraft.world.level.Level; +import net.minecraftforge.items.ItemHandlerHelper; + +import java.util.ArrayList; +import java.util.List; + +/* + This class mostly contains code extracted from + com.simibubi.create.content.logistics.stockTicker.StockTickerInteractionHandler.interactWithShop() + in order to facilitate deferring the order placement. We need to call different parts of that one function at different times. + + As such, it has been isolated to its own class. If the interactWithShop() function changes, most of the impact + will be to this file, and the mixin which kicks off the whole deferred checkout process + */ +public class NumismaticsCheckoutUtilitiesImpl extends NumismaticsCheckoutUtilities { + + public static void shopInteractionSubmitToNetwork(StockTickerBlockEntity tickerBE, PackageOrder order, Player player, Level level, String packageAddress) { + tickerBE.broadcastPackageRequest(LogisticallyLinkedBehaviour.RequestType.PLAYER, order, null, packageAddress); + if (player.getItemInHand(InteractionHand.MAIN_HAND).getItem() instanceof ShoppingListItem) + player.setItemInHand(InteractionHand.MAIN_HAND, ItemStack.EMPTY); + if (!order.isEmpty()) + AllSoundEvents.STOCK_TICKER_TRADE.playOnServer(level, tickerBE.getBlockPos()); + } + + public static boolean checkOrderPreconditions(StockTickerBlockEntity tickerBE, PackageOrder order, Level level, Player player) { + // Must be up-to-date + tickerBE.getAccurateSummary(); + + // Check stock levels + InventorySummary recentSummary = tickerBE.getRecentSummary(); + for (BigItemStack entry : order.stacks()) { + if (recentSummary.getCountOf(entry.stack) >= entry.count) + continue; + + denyPurchase(level, player, "create.stock_keeper.stock_level_too_low"); + return false; + } + return true; + } + + /* + The "Stock" in the name of this function refers to "Standard" as in this is what we'll call to invoke the standard + process of placing an order if the order also contains items, instead of just coins. + */ + public static boolean finishShopInteractionStock( + StockTickerBlockEntity tickerBE, + Level level, + Player player, + InventorySummary paymentEntries, + PackageOrder order, + SmartInventory receivedPayments, + String packageAddress) { + if (!checkOrderPreconditions(tickerBE, order, level, player)) + return false; + + // Check space in stock ticker + int occupiedSlots = 0; + for (BigItemStack entry : paymentEntries.getStacksByCount()) + occupiedSlots += Mth.ceil(entry.count / (float) entry.stack.getMaxStackSize()); + for (int i = 0; i < receivedPayments.getSlots(); i++) + if (receivedPayments.getStackInSlot(i) + .isEmpty()) + occupiedSlots--; + + if (occupiedSlots > 0) { + denyPurchase(level, player, "create.stock_keeper.cash_register_full"); + return false; + } + + // Transfer payment to stock ticker + for (boolean simulate : Iterate.trueAndFalse) { + InventorySummary tally = paymentEntries.copy(); + List toTransfer = new ArrayList<>(); + + for (int i = 0; i < player.getInventory().items.size(); i++) { + ItemStack item = player.getInventory() + .getItem(i); + if (item.isEmpty()) + continue; + int countOf = tally.getCountOf(item); + if (countOf == 0) + continue; + int toRemove = Math.min(item.getCount(), countOf); + tally.add(item, -toRemove); + + if (simulate) + continue; + + int newStackSize = item.getCount() - toRemove; + player.getInventory() + .setItem(i, newStackSize == 0 ? ItemStack.EMPTY : item.copyWithCount(newStackSize)); + toTransfer.add(item.copyWithCount(toRemove)); + } + + if (simulate && tally.getTotalCount() != 0) { + denyPurchase(level, player, "create.stock_keeper.too_broke"); + return false; + } + + if (simulate) + continue; + + toTransfer.forEach(s -> ItemHandlerHelper.insertItemStacked(tickerBE.getReceivedPaymentsHandler(), s, false)); + } + + shopInteractionSubmitToNetwork(tickerBE, order, player, level, packageAddress); + return true; + } + + public static void denyPurchase(Level level, Player player, String langKey) { + AllSoundEvents.DENY.playOnServer(level, player.blockPosition()); + player.displayClientMessage(Component.translatable(langKey).withStyle(ChatFormatting.RED), true); + } +}