diff --git a/build.gradle.kts b/build.gradle.kts index 5719842e..c410c950 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -296,7 +296,7 @@ tasks.register("numismaticsPublish") { fun Project.setupRepositories() { repositories { mavenCentral() - maven("https://maven.neoforged.net") // NeoForge + maven("https://maven.neoforged.net/releases") // NeoForge maven("https://maven.createmod.net") // Create, Ponder, Flywheel maven("https://mvn.devos.one/snapshots/") // Create Fabric, Registrate Fabric, Milk Lib, Dripstone Lib diff --git a/common/src/generated/resources/.cache/4a647960c58f00edc4e31df653bfe3586eff8c6c b/common/src/generated/resources/.cache/4a647960c58f00edc4e31df653bfe3586eff8c6c index 0651b96d..585f3f05 100644 --- a/common/src/generated/resources/.cache/4a647960c58f00edc4e31df653bfe3586eff8c6c +++ b/common/src/generated/resources/.cache/4a647960c58f00edc4e31df653bfe3586eff8c6c @@ -1,19 +1,19 @@ -// 1.21.1 2026-01-02T12:48:08.192801868 Registrate Provider for numismatics [Registries, Data Maps, Recipes, Advancements, Loot Tables, Tags (blocks), Tags (enchantments), Tags (items), Tags (fluids), Tags (entity_types), generic_server_provider, Blockstates, Item models, Lang (en_us/en_ud), generic_client_provider] +// 1.21.1 2025-08-12T23:13:17.5266632 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 -6dd2c5d0c4c607aa601fb66217f4f60a5c008489 assets/numismatics/lang/en_ud.json -bb6968537ab305ee37b6268f9037a689b8c8a9c9 assets/numismatics/lang/en_us.json +7876db60df471405f6593ff4e01569bc6e60c852 assets/numismatics/lang/en_ud.json +0807126a759d0f21c95f49e53178463e07a491d8 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 -228b67a48aa045bfe809c54c756df80eb0765aad assets/numismatics/models/item/bank_terminal.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 70c481f36a9718ac48632e6939ac6ba785be4c9e assets/numismatics/models/item/black_id_card.json @@ -56,9 +56,9 @@ c1863c2bd08a5910a534aee0dcbc61a352fb9577 assets/numismatics/models/item/white_ca 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 -a615f3af71b117b4f5974a64a1c744ff072fba54 data/c/tags/block/relocation_not_supported.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 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 5cf35fdd..cf021148 100644 --- a/common/src/main/java/dev/ithundxr/createnumismatics/Numismatics.java +++ b/common/src/main/java/dev/ithundxr/createnumismatics/Numismatics.java @@ -8,6 +8,7 @@ import com.simibubi.create.foundation.item.TooltipModifier; import dev.architectury.injectables.annotations.ExpectPlatform; 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.NumismaticsCommands; import dev.ithundxr.createnumismatics.registry.NumismaticsCreativeModeTabs.Tabs; @@ -30,6 +31,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 22096192..f3401099 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 @@ -1,7 +1,6 @@ package dev.ithundxr.createnumismatics.content.backend; import com.google.common.collect.ImmutableList; -import com.mojang.serialization.Codec; import com.simibubi.create.foundation.blockEntity.behaviour.scrollValue.INamedIconOptions; import com.simibubi.create.foundation.gui.AllIcons; import dev.ithundxr.createnumismatics.registry.NumismaticsIcons; @@ -11,13 +10,11 @@ import net.createmod.catnip.codecs.stream.CatnipStreamCodecBuilders; import net.createmod.catnip.data.Couple; import net.minecraft.network.chat.Component; -import net.minecraft.network.codec.ByteBufCodecs; import net.minecraft.network.codec.StreamCodec; -import net.minecraft.util.StringRepresentable; import net.minecraft.world.item.ItemStack; import net.minecraft.world.item.Rarity; -import org.jetbrains.annotations.NotNull; +import java.util.Collections; import java.util.List; import java.util.Locale; @@ -41,6 +38,8 @@ public enum Coin implements INamedIconOptions { public static final StreamCodec STREAM_CODEC = CatnipStreamCodecBuilders.ofEnum(Coin.class); + public static final Coin[] VALUES = values(); + public final int value; // in terms of spurs public final Rarity rarity; public final NumismaticsIcons icon; 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..b6250faf --- /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 net.minecraft.network.RegistryFriendlyByteBuf; +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 org.jetbrains.annotations.Nullable; + +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 @Nullable UUID currentCardUUID; + + public CheckoutMenu(MenuType type, int id, Inventory inv, RegistryFriendlyByteBuf 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(RegistryFriendlyByteBuf 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..b04023dd --- /dev/null +++ b/common/src/main/java/dev/ithundxr/createnumismatics/content/checkout/CheckoutPaymentMethod.java @@ -0,0 +1,14 @@ +package dev.ithundxr.createnumismatics.content.checkout; + +import io.netty.buffer.ByteBuf; +import net.createmod.catnip.codecs.stream.CatnipStreamCodecBuilders; +import net.minecraft.network.codec.StreamCodec; + +public enum CheckoutPaymentMethod { + CANCEL_TRANSACTION, + CARD, + COINS; + + public static final StreamCodec STREAM_CODEC = CatnipStreamCodecBuilders.ofEnum(CheckoutPaymentMethod.class); + +} 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..039a3e1c --- /dev/null +++ b/common/src/main/java/dev/ithundxr/createnumismatics/content/checkout/CheckoutScreen.java @@ -0,0 +1,159 @@ +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.content.backend.Coin; +import dev.ithundxr.createnumismatics.content.coins.CoinItem; +import dev.ithundxr.createnumismatics.registry.NumismaticsGuiTextures; +import dev.ithundxr.createnumismatics.registry.packets.DeferredCheckoutResolutionPacket; +import dev.ithundxr.createnumismatics.util.TextUtils; +import net.createmod.catnip.data.Couple; +import net.createmod.catnip.gui.element.GuiGameElement; +import net.createmod.catnip.platform.CatnipServices; +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 != null; + } + + @Override + public void onClose() { + onCancelTransaction(); + } + + private void onPayWithCoins() { + onConfirmTransaction(CheckoutPaymentMethod.COINS); + } + + private void onPayWithCard() { + onConfirmTransaction(CheckoutPaymentMethod.CARD); + } + + private void onConfirmTransaction(CheckoutPaymentMethod method) { + CatnipServices.NETWORK.sendToServer(new DeferredCheckoutResolutionPacket(this.menu.contentHolder.id(), method, menu.currentCardUUID)); + super.onClose(); + } + + private void onCancelTransaction() { + CatnipServices.NETWORK.sendToServer(new DeferredCheckoutResolutionPacket(this.menu.contentHolder.id(), CheckoutPaymentMethod.CANCEL_TRANSACTION, null)); + 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..e785d7f9 --- /dev/null +++ b/common/src/main/java/dev/ithundxr/createnumismatics/content/checkout/DeferredCheckoutOrder.java @@ -0,0 +1,327 @@ +package dev.ithundxr.createnumismatics.content.checkout; + +import com.simibubi.create.content.logistics.BigItemStack; +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 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 (BigItemStack 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, @Nullable UUID purchasingAccountId) { + if (method == CheckoutPaymentMethod.CANCEL_TRANSACTION) + return false; + + BankAccount account = null; + if (method == CheckoutPaymentMethod.CARD) { + if (purchasingAccountId == null) { + 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; + + Coin[] coins = Coin.VALUES; + for (int i = coins.length - 1; i >= 0; i--) { + Coin coin = coins[i]; + + 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.VALUES) { + 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 ca92678e..ab8dba71 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,26 @@ public static DiscreteCoinBag of(Map coins) { return new DiscreteCoinBag(coins); } + public static DiscreteCoinBag ofGreedy(int totalSpurValue) { + DiscreteCoinBag bag = new DiscreteCoinBag(); + int spurs = totalSpurValue; + + Coin[] coins = Coin.VALUES; + for (int i = coins.length - 1; i >= 0; i--) { + Coin coin = coins[i]; + + Couple 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(); } @@ -133,8 +155,6 @@ public void dropContents(Level level, Entity entityAt) { } private void dropContents(Level level, double x, double y, double z) { - coins.forEach((coin, amount) -> { - Containers.dropItemStack(level, x, y, z, coin.asStack(amount)); - }); + coins.forEach((coin, amount) -> Containers.dropItemStack(level, x, y, z, coin.asStack(amount))); } } 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 396f1121..077fdb95 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 f95581f7..876d92db 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 6be5a273..b583c386 100644 --- a/common/src/main/java/dev/ithundxr/createnumismatics/registry/NumismaticsPackets.java +++ b/common/src/main/java/dev/ithundxr/createnumismatics/registry/NumismaticsPackets.java @@ -20,6 +20,7 @@ public enum NumismaticsPackets implements PacketTypeProvider { ANDESITE_DEPOSITOR_CONFIGURATION(AndesiteDepositorConfigurationPacket.class, AndesiteDepositorConfigurationPacket.STREAM_CODEC), OPEN_TRUST_LIST(OpenTrustListPacket.class, OpenTrustListPacket.STREAM_CODEC), VENDOR_CONFIGURATION(VendorConfigurationPacket.class, VendorConfigurationPacket.STREAM_CODEC), + DEFERRED_CHECKOUT_RESOLUTION(DeferredCheckoutResolutionPacket.class, DeferredCheckoutResolutionPacket.STREAM_CODEC), // S2C BANK_ACCOUNT_LABEL(BankAccountLabelPacket.class, BankAccountLabelPacket.STREAM_CODEC), 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..4939ea30 --- /dev/null +++ b/common/src/main/java/dev/ithundxr/createnumismatics/registry/packets/DeferredCheckoutResolutionPacket.java @@ -0,0 +1,39 @@ +package dev.ithundxr.createnumismatics.registry.packets; + +import dev.ithundxr.createnumismatics.Numismatics; +import dev.ithundxr.createnumismatics.content.checkout.CheckoutPaymentMethod; +import dev.ithundxr.createnumismatics.registry.NumismaticsPackets; +import io.netty.buffer.ByteBuf; +import net.createmod.catnip.codecs.stream.CatnipStreamCodecBuilders; +import net.createmod.catnip.net.base.ServerboundPacketPayload; +import net.minecraft.core.UUIDUtil; +import net.minecraft.network.codec.StreamCodec; +import net.minecraft.server.level.ServerPlayer; +import org.jetbrains.annotations.Nullable; + +import java.util.UUID; + +public record DeferredCheckoutResolutionPacket( + UUID transactionId, CheckoutPaymentMethod method, @Nullable UUID bankAccount) implements ServerboundPacketPayload { + public static final StreamCodec STREAM_CODEC = StreamCodec.composite( + UUIDUtil.STREAM_CODEC, DeferredCheckoutResolutionPacket::transactionId, + CheckoutPaymentMethod.STREAM_CODEC, DeferredCheckoutResolutionPacket::method, + CatnipStreamCodecBuilders.nullable(UUIDUtil.STREAM_CODEC), DeferredCheckoutResolutionPacket::bankAccount, + DeferredCheckoutResolutionPacket::new + ); + + @Override + public void handle(ServerPlayer player) { + var order = Numismatics.DEFERRED_ORDERS.getDeferredOrder(transactionId); + if (order == null) + return; + + order.completePurchase(method, bankAccount); + Numismatics.DEFERRED_ORDERS.voidOrder(order); + } + + @Override + public PacketTypeProvider getTypeProvider() { + return NumismaticsPackets.DEFERRED_CHECKOUT_RESOLUTION; + } +} 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 9fa761f9..db4299ad 100644 --- a/common/src/main/java/dev/ithundxr/createnumismatics/util/Utils.java +++ b/common/src/main/java/dev/ithundxr/createnumismatics/util/Utils.java @@ -2,7 +2,6 @@ import dev.architectury.injectables.annotations.ExpectPlatform; import dev.ithundxr.createnumismatics.multiloader.Env; -import net.minecraft.network.FriendlyByteBuf; import net.minecraft.network.RegistryFriendlyByteBuf; import net.minecraft.server.level.ServerPlayer; import net.minecraft.world.MenuProvider; 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 b69e72cc..efbc200e 100644 --- a/common/src/main/resources/assets/numismatics/lang/default/interface.json +++ b/common/src/main/resources/assets/numismatics/lang/default/interface.json @@ -21,6 +21,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 e96df23b..b59fa293 100644 --- a/common/src/main/resources/numismatics-common.mixins.json +++ b/common/src/main/resources/numismatics-common.mixins.json @@ -10,6 +10,8 @@ "mixins": [ "MixinAbstractContainerMenu", "MixinServerPlayer", + "MixinStockTickerInteractionHandler", + "MixinStockTickerBlockEntityReceivedPaymentsAccessor", "compat.carryon.MixinPickupHandler" ], "injectors": { diff --git a/neoforge/src/main/java/dev/ithundxr/createnumismatics/multiloader/neoforge/NumismaticsCheckoutUtilitiesImpl.java b/neoforge/src/main/java/dev/ithundxr/createnumismatics/multiloader/neoforge/NumismaticsCheckoutUtilitiesImpl.java new file mode 100644 index 00000000..d87098ec --- /dev/null +++ b/neoforge/src/main/java/dev/ithundxr/createnumismatics/multiloader/neoforge/NumismaticsCheckoutUtilitiesImpl.java @@ -0,0 +1,132 @@ +package dev.ithundxr.createnumismatics.multiloader.neoforge; + +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.neoforged.neoforge.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(receivedPayments, 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); + } +} diff --git a/settings.gradle.kts b/settings.gradle.kts index 4717b851..bd08a174 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -2,7 +2,7 @@ pluginManagement { repositories { maven("https://maven.fabricmc.net/") maven("https://maven.architectury.dev/") - maven("https://maven.neoforged.net/") + maven("https://maven.neoforged.net/releases") gradlePluginPortal() } }