diff --git a/build.gradle.kts b/build.gradle.kts index 5cb43fa..d224229 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -11,11 +11,12 @@ plugins { } repositories { - mavenCentral() maven("https://repo.papermc.io/repository/maven-public/") } +val exposedVersion = "1.0.0-rc-4" + dependencies { compileOnly("io.papermc.paper:paper-api:1.21.10-R0.1-SNAPSHOT") compileOnly("org.jetbrains.kotlin:kotlin-stdlib") @@ -25,8 +26,17 @@ dependencies { compileOnly("net.hareworks.hcu:economy:1.0") compileOnly("net.hareworks.hcu:faction:1.0") + compileOnly("net.hareworks:kommand-lib") + compileOnly("net.hareworks:permits-lib") + // Libs compileOnly("com.michael-bull.kotlin-result:kotlin-result:2.1.0") + + val exposedVersion = "1.0.0-rc-4" + + compileOnly("org.jetbrains.exposed:exposed-core:$exposedVersion") + compileOnly("org.jetbrains.exposed:exposed-dao:$exposedVersion") + compileOnly("org.jetbrains.exposed:exposed-jdbc:$exposedVersion") } tasks { diff --git a/src/main/kotlin/net/hareworks/hcu/shop/ShopPlugin.kt b/src/main/kotlin/net/hareworks/hcu/shop/ShopPlugin.kt index f041c36..b6d951f 100644 --- a/src/main/kotlin/net/hareworks/hcu/shop/ShopPlugin.kt +++ b/src/main/kotlin/net/hareworks/hcu/shop/ShopPlugin.kt @@ -1,13 +1,74 @@ package net.hareworks.hcu.shop +import net.hareworks.hcu.shop.command.ShopCommands +import net.hareworks.hcu.shop.listeners.ShopListener +import net.hareworks.kommand_lib.KommandLib +import net.hareworks.permits_lib.PermitsLib import org.bukkit.plugin.java.JavaPlugin +import net.hareworks.hcu.shop.database.HcuDatabase +import net.hareworks.hcu.shop.database.ShopRepository +import org.bukkit.scheduler.BukkitTask +import java.util.logging.Level class ShopPlugin : JavaPlugin() { + + private var commands: KommandLib? = null + private val permits = PermitsLib.session(this) + override fun onEnable() { + // Register Command via KommandLib + commands = ShopCommands.register(this, permits) + + // Register Listener + server.pluginManager.registerEvents(ShopListener(this), this) + + // Initialize Database handled by readiness monitor + // net.hareworks.hcu.shop.database.ShopRepository.init() + monitorDatabaseReadiness() + logger.info("Shop plugin enabled!") } override fun onDisable() { + readinessTask?.cancel() + readinessTask = null + databaseReady = false + commands?.unregister() + commands = null logger.info("Shop plugin disabled!") } + + private var readinessTask: BukkitTask? = null + @Volatile private var databaseReady: Boolean = false + + private fun monitorDatabaseReadiness() { + readinessTask?.cancel() + readinessTask = server.scheduler.runTaskTimer( + this, + Runnable { + if (!HcuDatabase.dependencyAvailable(this)) { + return@Runnable + } + if (!HcuDatabase.sessionAvailable()) { + return@Runnable + } + if (databaseReady) return@Runnable + + databaseReady = true + readinessTask?.cancel() + readinessTask = null + + // Init Repository + runCatching { + ShopRepository.init() + logger.info("Shop database initialized.") + }.onFailure { + logger.log(Level.SEVERE, "Failed to initialize Shop database", it) + server.pluginManager.disablePlugin(this) + } + }, + 20L, + 100L + ) + } } diff --git a/src/main/kotlin/net/hareworks/hcu/shop/command/ShopCommands.kt b/src/main/kotlin/net/hareworks/hcu/shop/command/ShopCommands.kt new file mode 100644 index 0000000..21003b6 --- /dev/null +++ b/src/main/kotlin/net/hareworks/hcu/shop/command/ShopCommands.kt @@ -0,0 +1,74 @@ +package net.hareworks.hcu.shop.command + +import net.hareworks.kommand_lib.KommandLib +import net.hareworks.kommand_lib.kommand +import net.hareworks.permits_lib.bukkit.MutationSession +import net.kyori.adventure.text.Component +import net.kyori.adventure.text.format.NamedTextColor +import org.bukkit.Material +import org.bukkit.NamespacedKey +import org.bukkit.entity.Player +import org.bukkit.inventory.ItemStack +import org.bukkit.persistence.PersistentDataType +import org.bukkit.plugin.java.JavaPlugin +import net.hareworks.hcu.shop.ShopPlugin + +object ShopCommands { + private val tierKeyStr = "shop_tier" + + fun register(plugin: ShopPlugin, permits: MutationSession): KommandLib { + // Need to access NamespacedKey. Ideally pass plugin or use the plugin instance passed to kommand + // plugin is available as 'plugin' in local scope or passed in. + // We'll calculate the NamespacedKey inside the command logic. + val tierKey = NamespacedKey(plugin, tierKeyStr) + + return kommand(plugin) { + permissions { + namespace = "shop" + session(permits) + } + + command("shop") { + description = "Shop commands" + + literal("give") { + permission { + description = "Give shop sign" + defaultValue = org.bukkit.permissions.PermissionDefault.OP + } + + // Logic for /shop give (No tier specified, default 1) + executes { + giveShopSign(plugin, sender, 1, tierKey) + } + + // Logic for /shop give + integer("tier", min = 1, max = 5) { + executes { + val tier: Int = argument("tier") + giveShopSign(plugin, sender, tier, tierKey) + } + } + } + } + } + } + + private fun giveShopSign(plugin: JavaPlugin, sender: org.bukkit.command.CommandSender, tier: Int, key: NamespacedKey) { + if (sender !is Player) { + sender.sendMessage(Component.text("Only players can use this command.", NamedTextColor.RED)) + return + } + + val signItem = ItemStack(Material.OAK_SIGN) + val meta = signItem.itemMeta + + meta.displayName(Component.text("Shop Sign (Tier $tier)").color(NamedTextColor.GOLD)) + meta.persistentDataContainer.set(key, PersistentDataType.INTEGER, tier) + + signItem.itemMeta = meta + + sender.inventory.addItem(signItem) + sender.sendMessage(Component.text("Given Shop Sign Tier $tier", NamedTextColor.GREEN)) + } +} diff --git a/src/main/kotlin/net/hareworks/hcu/shop/database/HcuDatabase.kt b/src/main/kotlin/net/hareworks/hcu/shop/database/HcuDatabase.kt new file mode 100644 index 0000000..b5b6dd4 --- /dev/null +++ b/src/main/kotlin/net/hareworks/hcu/shop/database/HcuDatabase.kt @@ -0,0 +1,16 @@ +package net.hareworks.hcu.shop.database + +import net.hareworks.hcu.core.database.DatabaseSessionManager +import org.bukkit.plugin.java.JavaPlugin +import org.jetbrains.exposed.v1.jdbc.JdbcTransaction + +object HcuDatabase { + fun dependencyAvailable(plugin: JavaPlugin): Boolean { + val dependency = plugin.server.pluginManager.getPlugin("hcu-core") + return dependency?.isEnabled == true + } + + fun sessionAvailable(): Boolean = DatabaseSessionManager.isConnected() && DatabaseSessionManager.ping() + + fun transaction(block: JdbcTransaction.() -> T): T = DatabaseSessionManager.transaction(block) +} diff --git a/src/main/kotlin/net/hareworks/hcu/shop/database/ShopRepository.kt b/src/main/kotlin/net/hareworks/hcu/shop/database/ShopRepository.kt new file mode 100644 index 0000000..d997844 --- /dev/null +++ b/src/main/kotlin/net/hareworks/hcu/shop/database/ShopRepository.kt @@ -0,0 +1,137 @@ +package net.hareworks.hcu.shop.database + +import net.hareworks.hcu.shop.ShopPlugin +import org.bukkit.Bukkit +import org.bukkit.Location +import org.bukkit.block.BlockFace +import org.bukkit.inventory.ItemStack +import org.jetbrains.exposed.v1.core.* +import org.jetbrains.exposed.v1.jdbc.SchemaUtils +import org.jetbrains.exposed.v1.jdbc.transactions.transaction +import org.jetbrains.exposed.v1.jdbc.insert +import org.jetbrains.exposed.v1.jdbc.selectAll +import org.jetbrains.exposed.v1.jdbc.update +import org.jetbrains.exposed.v1.jdbc.deleteWhere +import java.util.UUID + +object ShopRepository { + + fun init() { + HcuDatabase.transaction { + SchemaUtils.create(Shops) + } + } + + fun create(actorId: Int, location: Location, face: String, item: ItemStack? = null, stock: Int = 0, price: Double = 0.0): ShopData? { + val world = location.world ?: return null + val itemBytes = item?.serializeAsBytes() + + return HcuDatabase.transaction { + val id = Shops.insert { + it[Shops.actorId] = actorId + it[Shops.worldUid] = world.uid + it[Shops.x] = location.blockX + it[Shops.y] = location.blockY + it[Shops.z] = location.blockZ + it[Shops.face] = face + it[Shops.stock] = stock + it[Shops.price] = price + it[Shops.item] = itemBytes + } get Shops.id + + ShopData( + id = id!!, + actorId = actorId, + worldUid = world.uid, + x = location.blockX, + y = location.blockY, + z = location.blockZ, + face = face, + price = price, + stock = stock, + item = item, + enabled = false + ) + } + } + + fun findByLocationAndFace(location: Location, face: String): ShopData? { + val world = location.world ?: return null + return HcuDatabase.transaction { + Shops.selectAll().where { + (Shops.worldUid eq world.uid) and + (Shops.x eq location.blockX) and + (Shops.y eq location.blockY) and + (Shops.z eq location.blockZ) and + (Shops.face eq face) + }.map { toShopData(it) } + .singleOrNull() + } + } + + fun findById(id: Int): ShopData? { + return HcuDatabase.transaction { + Shops.selectAll().where { Shops.id eq id } + .map { toShopData(it) } + .singleOrNull() + } + } + + fun updateStock(id: Int, newStock: Int) { + HcuDatabase.transaction { + Shops.update({ Shops.id eq id }) { + it[Shops.stock] = newStock + } + } + } + + fun updatePrice(id: Int, newPrice: Double) { + HcuDatabase.transaction { + Shops.update({ Shops.id eq id }) { + it[Shops.price] = newPrice + } + } + } + + fun updateItem(id: Int, newItem: ItemStack?) { + val itemBytes = newItem?.serializeAsBytes() + HcuDatabase.transaction { + Shops.update({ Shops.id eq id }) { + it[Shops.item] = itemBytes + } + } + } + + fun updateEnabled(id: Int, enabled: Boolean) { + HcuDatabase.transaction { + Shops.update({ Shops.id eq id }) { + it[Shops.enabled] = enabled + } + } + } + + fun delete(targetId: Int) { + HcuDatabase.transaction { + Shops.deleteWhere { Shops.id eq targetId } + } + } + + private fun toShopData(row: ResultRow): ShopData { + val itemBytes = row[Shops.item] + val item = itemBytes?.let { ItemStack.deserializeBytes(it) } + + return ShopData( + id = row[Shops.id], + actorId = row[Shops.actorId], + worldUid = row[Shops.worldUid], + x = row[Shops.x], + y = row[Shops.y], + z = row[Shops.z], + face = row[Shops.face], + price = row[Shops.price], + stock = row[Shops.stock], + item = item, + enabled = row[Shops.enabled] + ) + } +} diff --git a/src/main/kotlin/net/hareworks/hcu/shop/database/Shops.kt b/src/main/kotlin/net/hareworks/hcu/shop/database/Shops.kt new file mode 100644 index 0000000..def2e16 --- /dev/null +++ b/src/main/kotlin/net/hareworks/hcu/shop/database/Shops.kt @@ -0,0 +1,37 @@ +package net.hareworks.hcu.shop.database + +import org.bukkit.inventory.ItemStack +import org.jetbrains.exposed.v1.core.Table + +object Shops : Table("shops") { + val id = integer("id").autoIncrement() + val actorId = integer("actor_id") + + // Position (Vector3i equivalent + World) + val worldUid = uuid("world_uid") + val x = integer("x") + val y = integer("y") + val z = integer("z") + val face = varchar("face", 16) // FRONT or BACK + + val price = double("price").default(0.0) + val stock = integer("stock") + val item = binary("item_bytes").nullable() // Item can be null initially. + val enabled = bool("enabled").default(false) + + override val primaryKey = PrimaryKey(id) +} + +data class ShopData( + val id: Int, + val actorId: Int, + val worldUid: java.util.UUID, + val x: Int, + val y: Int, + val z: Int, + val face: String, + val price: Double, + val stock: Int, + val item: ItemStack?, + val enabled: Boolean +) diff --git a/src/main/kotlin/net/hareworks/hcu/shop/listeners/ShopListener.kt b/src/main/kotlin/net/hareworks/hcu/shop/listeners/ShopListener.kt new file mode 100644 index 0000000..c8717eb --- /dev/null +++ b/src/main/kotlin/net/hareworks/hcu/shop/listeners/ShopListener.kt @@ -0,0 +1,548 @@ +package net.hareworks.hcu.shop.listeners +import net.hareworks.hcu.shop.ShopPlugin +import net.hareworks.hcu.shop.database.ShopRepository +import net.hareworks.hcu.core.player.PlayerIdServiceImpl +import net.kyori.adventure.text.Component +import net.kyori.adventure.text.format.NamedTextColor +import org.bukkit.DyeColor +import org.bukkit.Material +import org.bukkit.NamespacedKey +import org.bukkit.block.BlockFace +import org.bukkit.block.Sign +import org.bukkit.block.data.Rotatable +import org.bukkit.block.sign.Side +import org.bukkit.entity.ItemDisplay +import org.bukkit.event.EventHandler +import org.bukkit.event.Listener +import org.bukkit.event.block.BlockPlaceEvent +import org.bukkit.event.block.SignChangeEvent +import org.bukkit.event.player.PlayerInteractEvent +import org.bukkit.event.block.Action +import org.bukkit.event.inventory.InventoryClickEvent +import org.bukkit.event.inventory.InventoryCloseEvent +import org.bukkit.event.inventory.InventoryType +import org.bukkit.inventory.Inventory +import org.bukkit.inventory.ItemStack +import org.bukkit.persistence.PersistentDataType +import org.bukkit.util.Transformation +import org.joml.Quaternionf +import org.joml.Vector3f + +class ShopListener(private val plugin: ShopPlugin) : Listener { + + private val tierKey = NamespacedKey(plugin, "shop_tier") + + @EventHandler + fun onShopSignPlace(event: BlockPlaceEvent) { + val item = event.itemInHand + val meta = item.itemMeta ?: return + + // Check if it is a shop sign + val tier = meta.persistentDataContainer.get(tierKey, PersistentDataType.INTEGER) ?: return + + // Check block type (Standing Sign) + if (event.blockPlaced.type != Material.OAK_SIGN) { + event.isCancelled = true + return + } + + // Check block against (Chest) + if (event.blockAgainst.type != Material.CHEST) { + // Only valid on top of chests + event.isCancelled = true + return + } + + // Apply Sign Text and Properties + val signState = event.blockPlaced.state as? Sign ?: return + + // Front Text + val frontSide = signState.getSide(Side.FRONT) + frontSide.line(0, Component.text("Front")) + frontSide.line(1, Component.text("oak_log")) + frontSide.color = DyeColor.BLACK + frontSide.isGlowingText = false + + // Back Text + val backSide = signState.getSide(Side.BACK) + backSide.line(0, Component.text("Back")) + backSide.line(1, Component.text("gunpowder")) + backSide.color = DyeColor.BLACK + backSide.isGlowingText = false + + // Save Tier to Block and Wax (Lock) + signState.persistentDataContainer.set(tierKey, PersistentDataType.INTEGER, tier) + signState.isWaxed = true + + signState.update() + + // --- Create Shop Records (DB) --- + // FIXME: Ideally get service via dependency injection or service manager + val playerIdService = PlayerIdServiceImpl(plugin.logger) + val playerEntry = playerIdService.find(event.player.uniqueId) + + if (playerEntry == null) { + plugin.logger.warning("Could not find actor ID for player ${event.player.name}") + // Consider cancelling if critical, but for now just log + } else { + val actorId = playerEntry.actorId + + // Create FRONT shop + ShopRepository.create( + actorId = actorId, + location = event.blockPlaced.location, + face = "FRONT" + ) + + // Create BACK shop + ShopRepository.create( + actorId = actorId, + location = event.blockPlaced.location, + face = "BACK" + ) + } + + // Spawn Displays + spawnDisplays(event.blockPlaced) + } + + private fun spawnDisplays(block: org.bukkit.block.Block) { + val origin = block.location.clone() + val data = block.blockData as? Rotatable ?: return + val rotation = data.rotation // BlockFace + + val yaw = yawFromBlockFace(rotation) + 90f + + // Calculate offsets relative to center (0.5, 0, 0.5) + // Original request assumes 0,0,0 is block origin. + // Front: 0.75 0.25 0.5 -> Rel: 0.25, 0.25, 0.0 + // Back: 0.25 0.25 0.5 -> Rel: -0.25, 0.25, 0.0 + + val frontOffset = Vector3f(0.25f, 0.25f, 0.0f) + val backOffset = Vector3f(-0.25f, 0.25f, 0.0f) + + // Rotate offsets + // yaw is degrees clockwise from South (Z+). + // radians need to be negated for standard CCW rotation logic if we use that, + // or just use rotateY with appropriate sign. + // rotateY in JOML: "Rotates this vector by the given angle in radians around the Y axis" + // Standard geometric Y rotation is CCW. + // Minecraft Yaw 90 (West) should rotate (0,0,1) to (-1,0,0). + // Std Rot 90 CCW: (0,0,1) -> (1,0,0). + // So they are opposite. We should rotate by -yaw. + + val rad = Math.toRadians(yaw.toDouble()) + frontOffset.rotateY(-rad.toFloat()) + backOffset.rotateY(-rad.toFloat()) + + // Final positions + val center = origin.clone().add(0.5, 0.0, 0.5) + val frontPos = center.clone().add(frontOffset.x.toDouble(), frontOffset.y.toDouble(), frontOffset.z.toDouble()) + val backPos = center.clone().add(backOffset.x.toDouble(), backOffset.y.toDouble(), backOffset.z.toDouble()) + + val world = block.world + + // Spawn Front + world.spawn(frontPos, ItemDisplay::class.java) { display -> + // display.setItemStack(ItemStack(Material.AIR)) // Default empty + display.setRotation(yaw, 0f) + + // Transformation + val left = Quaternionf(0.0f, -0.7071068f, 0.0f, 0.7071068f) + val right = Quaternionf(0.0f, 0.0f, 0.0f, 1.0f) + val scale = Vector3f(0.5f, 0.5f, 0.5f) + val translation = Vector3f(0.0f, 0.0f, 0.0f) + + display.setTransformation(Transformation(translation, left, scale, right)) + display.interpolationDuration = 0 + + // Add custom tag or PDC to link to sign if needed? + } + + // Spawn Back + world.spawn(backPos, ItemDisplay::class.java) { display -> + // display.setItemStack(ItemStack(Material.AIR)) // Default empty + display.setRotation(yaw - 90f, 0f) + + // Transformation + val left = Quaternionf(0.0f, 1.0f, 0.0f, 0.0f) + val right = Quaternionf(0.0f, 0.0f, 0.0f, 1.0f) + val scale = Vector3f(0.5f, 0.5f, 0.5f) + val translation = Vector3f(0.0f, 0.0f, 0.0f) + + display.setTransformation(Transformation(translation, left, scale, right)) + display.interpolationDuration = 0 + } + } + + private fun yawFromBlockFace(face: BlockFace): Float { + return when (face) { + BlockFace.SOUTH -> 0f + BlockFace.SOUTH_SOUTH_WEST -> 22.5f + BlockFace.SOUTH_WEST -> 45f + BlockFace.WEST_SOUTH_WEST -> 67.5f + BlockFace.WEST -> 90f + BlockFace.WEST_NORTH_WEST -> 112.5f + BlockFace.NORTH_WEST -> 135f + BlockFace.NORTH_NORTH_WEST -> 157.5f + BlockFace.NORTH -> 180f + BlockFace.NORTH_NORTH_EAST -> 202.5f + BlockFace.NORTH_EAST -> 225f + BlockFace.EAST_NORTH_EAST -> 247.5f + BlockFace.EAST -> 270f + BlockFace.EAST_SOUTH_EAST -> 292.5f + BlockFace.SOUTH_EAST -> 315f + BlockFace.SOUTH_SOUTH_EAST -> 337.5f + else -> 0f + } + } + + @EventHandler + fun onSignChange(event: SignChangeEvent) { + val player = event.player + val shopId = ShopInputMap.pendingInputs[player.uniqueId] + + if (shopId != null) { + // Retrieve Shop Data + val shopData = ShopRepository.findById(shopId) + if (shopData == null) { + ShopInputMap.pendingInputs.remove(player.uniqueId) + return + } + + // Handle Shop Setup Input + // Line 0: Price (User Input) + val priceInput = event.line(0) + var priceString = (priceInput as? net.kyori.adventure.text.TextComponent)?.content() ?: "" + priceString = priceString.replace("Price : ", "", true).trim() + + val price = priceString.toDoubleOrNull() + if (price != null && price >= 0) { + // Update DB + ShopRepository.updatePrice(shopId, price) + player.sendMessage(Component.text("Shop price set to $$price", NamedTextColor.GREEN)) + + // Update Actual Sign Visuals + updateShopSignVisuals(event.block, shopData.copy(price = price), event.side) + } else { + player.sendMessage(Component.text("Invalid price format. Please enter a valid number.", NamedTextColor.RED)) + // Optionally reopen? For now just fail. + } + + // Clean up + ShopInputMap.pendingInputs.remove(player.uniqueId) + + // Cancel the raw update so we don't just paste "100" on the sign + // But we DO want to update it with the visual method above. + // Since we called updateShopSignVisuals which does state.update(), + // verifying if cancelling event interferes. + // Usually, event changes the state *after* this handler. + // If we cancel, no change happens. + // So we cancel, and manually set the text we want. + event.isCancelled = true + + } else { + // Normal sign editing protection + val state = event.block.state as? Sign ?: return + if (state.persistentDataContainer.has(tierKey, PersistentDataType.INTEGER)) { + // If strictly preventing editing of shop signs unless in setup mode + event.isCancelled = true + } + } + } + + private fun updateShopSignVisuals(block: org.bukkit.block.Block, shopData: net.hareworks.hcu.shop.database.ShopData, side: Side) { + val state = block.state as? Sign ?: return + val signSide = state.getSide(side) // Currently updating the side being edited + + // Fetch item name + val itemName = shopData.item?.type?.name?.lowercase()?.replace("_", " ") ?: "Unknown" + + // Format: + // L1: + // L2: Buy: + // L3: Stock: + + signSide.line(0, Component.text(itemName, NamedTextColor.BLACK)) + signSide.line(1, Component.text("Buy: $${shopData.price}", NamedTextColor.BLACK)) + signSide.line(2, Component.text("Stock: ${shopData.stock}", NamedTextColor.BLACK)) + signSide.line(3, Component.empty()) + + // If updating the sign block during event cancellation, we might need a scheduler or force update + // But since we are cancelling the event, the state remains as is. + // We need to apply THESE changes. + // state.update() pushes to world. + + // Wait, if we are in SignChangeEvent, the block is currently processing update. + // It's safer to run a task to update it 1 tick later if we cancel the event. + plugin.server.scheduler.runTask(plugin, Runnable { + state.update() + }) + } + + @EventHandler + fun onShopInteract(event: PlayerInteractEvent) { + if (event.action != Action.RIGHT_CLICK_BLOCK) return + val block = event.clickedBlock ?: return + val state = block.state as? Sign ?: return + + // Check if shop sign + if (!state.persistentDataContainer.has(tierKey, PersistentDataType.INTEGER)) return + + // Determine Face (Front/Back) + val data = block.blockData as? Rotatable ?: return + val rotation = data.rotation + val clickedFace = event.blockFace + + val face = when (clickedFace) { + rotation -> "FRONT" + rotation.oppositeFace -> "BACK" + else -> return // Clicked side/top/bottom, ignore or handle? Ignoring for now. + } + + // Check Owner + val playerIdService = PlayerIdServiceImpl(plugin.logger) + val playerEntry = playerIdService.find(event.player.uniqueId) ?: return + + // Find Shop Data + val shopData = ShopRepository.findByLocationAndFace(block.location, face) + + if (shopData != null) { + if (shopData.actorId == playerEntry.actorId) { + // Is Owner -> Open Edit GUI + openEditGui(event.player, shopData) + } else { + // Is Customer -> Buy Logic (TODO) + event.player.sendMessage(Component.text("Customer interactions not implemented yet.", NamedTextColor.GRAY)) + } + } else { + plugin.logger.warning("Shop data missing for location ${block.location} face $face") + } + } + + private fun openEditGui(player: org.bukkit.entity.Player, shopData: net.hareworks.hcu.shop.database.ShopData) { + // 1x5 UI using Hopper + val inventory = org.bukkit.Bukkit.createInventory(player, InventoryType.HOPPER, Component.text("Shop Setup: ${shopData.face}")) + + // Slot 0: Toggle Enabled + val toggleItem = if (shopData.enabled) { + ItemStack(Material.LIME_STAINED_GLASS_PANE).apply { + editMeta { it.displayName(Component.text("Enabled", NamedTextColor.GREEN)) } + } + } else { + ItemStack(Material.RED_STAINED_GLASS_PANE).apply { + editMeta { it.displayName(Component.text("Disabled", NamedTextColor.RED)) } + } + } + inventory.setItem(0, toggleItem) + + // Filler + val filler = ItemStack(Material.GRAY_STAINED_GLASS_PANE).apply { + editMeta { it.displayName(Component.empty()) } + } + inventory.setItem(1, filler) + inventory.setItem(3, filler) + + // Slot 4: Delete Item (Barrier) + val deleteItem = ItemStack(Material.BARRIER).apply { + editMeta { it.displayName(Component.text("Remove Item", NamedTextColor.RED)) } + } + inventory.setItem(4, deleteItem) + + // Setup center slot (Index 2) + if (shopData.item != null) { + val displayItem = shopData.item.clone() + // If we want to show stock/price info in lore, do it here + inventory.setItem(2, displayItem) + } else { + val displayItem = ItemStack(Material.BARRIER) + val meta = displayItem.itemMeta + meta.displayName(Component.text("Not Set", NamedTextColor.RED)) + displayItem.itemMeta = meta + inventory.setItem(2, displayItem) + } + + // We need to persist the shopId in the inventory holder or some map to handle events + // Since InventoryHolder is complex with chest GUIs, we can use a Holder class or a Map. + // For simplicity, let's assume we handle it in InventoryClickEvent by checking title or holding a map. + // A custom Holder is cleaner. + + player.openInventory(inventory) + ShopGuiMap.openInvs[inventory] = shopData.id + } + + @EventHandler + fun onShopGuiClick(event: InventoryClickEvent) { + val inventory = event.inventory + val shopId = ShopGuiMap.openInvs[inventory] ?: return + + // Allow player inventory interaction + if (event.clickedInventory != inventory) { + if (event.isShiftClick) event.isCancelled = true + return + } + + event.isCancelled = true // Always lock + + // Handle Slot 0: Toggle Logic + if (event.clickedInventory == inventory && event.slot == 0) { + val shopData = ShopRepository.findById(shopId) ?: return + val newState = !shopData.enabled + ShopRepository.updateEnabled(shopId, newState) + + // Update Icon + val newItem = if (newState) { + ItemStack(Material.LIME_STAINED_GLASS_PANE).apply { + editMeta { it.displayName(Component.text("Enabled", NamedTextColor.GREEN)) } + } + } else { + ItemStack(Material.RED_STAINED_GLASS_PANE).apply { + editMeta { it.displayName(Component.text("Disabled", NamedTextColor.RED)) } + } + } + inventory.setItem(0, newItem) + // Play sound? + } + + // Handle Slot 4: Remove Item + if (event.clickedInventory == inventory && event.slot == 4) { + // Remove item + ShopRepository.updateItem(shopId, null) + + // Update UI Center to Barrier + val barrier = ItemStack(Material.BARRIER).apply { + editMeta { it.displayName(Component.text("Not Set", NamedTextColor.RED)) } + } + inventory.setItem(2, barrier) + + // Update Display + val updatedShop = ShopRepository.findById(shopId) + if (updatedShop != null) { + updateShopDisplay(updatedShop) + } + } + + // Handle item placement in slot 2 + if (event.clickedInventory == inventory && event.slot == 2) { + val cursor = event.cursor + + // If clicking with an item (Icon/Amount setting) + if (cursor != null && cursor.type != Material.AIR) { + // Update Item in DB (Use clone to capture Type, Meta, and Amount) + val newItem = cursor.clone() + ShopRepository.updateItem(shopId, newItem) + + // Visual Update (Ghost Item) + inventory.setItem(2, newItem) + + // Update World Display + val updatedShop = ShopRepository.findById(shopId) + if (updatedShop != null) { + updateShopDisplay(updatedShop) + } + + // Prompt for Price via Sign + plugin.server.scheduler.runTask(plugin, Runnable { + event.whoClicked.closeInventory() + openPriceInputSign(event.whoClicked as org.bukkit.entity.Player, shopId) + }) + } else { + // Clicking with empty cursor on existing item -> Update Price + val currentItem = inventory.getItem(2) + if (currentItem != null && currentItem.type != Material.BARRIER && currentItem.type != Material.AIR) { + plugin.server.scheduler.runTask(plugin, Runnable { + event.whoClicked.closeInventory() + openPriceInputSign(event.whoClicked as org.bukkit.entity.Player, shopId) + }) + } + } + // If clicking with air (Removal?) -> Optional: Clear item + // else if (cursor == null || cursor.type == Material.AIR) { + // ShopRepository.updateItem(shopId, null) // Needs nullable update support + // inventory.setItem(2, null) + // } + } + } + + @EventHandler + fun onShopGuiClose(event: InventoryCloseEvent) { + ShopGuiMap.openInvs.remove(event.inventory) + } + + private fun openPriceInputSign(player: org.bukkit.entity.Player, shopId: Int) { + val shopData = ShopRepository.findById(shopId) ?: return + val location = org.bukkit.Location( + org.bukkit.Bukkit.getWorld(shopData.worldUid), + shopData.x.toDouble(), + shopData.y.toDouble(), + shopData.z.toDouble() + ) + val block = location.block + if (block.type != Material.OAK_SIGN) return + + val sign = block.state as? Sign ?: return + + // Determine side based on ShopData face + val side = if (shopData.face == "FRONT") Side.FRONT else Side.BACK + val signSide = sign.getSide(side) + + // Send client-side update with prompt + // Using Paper API: player.sendBlockUpdate(location, blockData) doesnt allow setting text easily + // We can modify the sign state locally and send update, then revert? + // Or actually, `player.sendBlockUpdate` takes a `TileState` in recent Paper versions? + // If not, we can use `player.sendSignChange`. + + val promptLines = listOf( + Component.text("Price : ", NamedTextColor.BLACK), + Component.empty(), + Component.empty(), + Component.empty() + ) + + player.sendSignChange(location, promptLines, DyeColor.BLACK, true) // hasGlowingText=true/false? + + // Register pending input + ShopInputMap.pendingInputs[player.uniqueId] = shopId + + // Open Editor + player.openSign(sign, side) + } + + private fun updateShopDisplay(shopData: net.hareworks.hcu.shop.database.ShopData) { + val world = org.bukkit.Bukkit.getWorld(shopData.worldUid) ?: return + val block = world.getBlockAt(shopData.x, shopData.y, shopData.z) + + // Ensure block is still a sign and rotatable + val data = block.blockData as? Rotatable ?: return + val rotation = data.rotation + + val yaw = yawFromBlockFace(rotation) + 90f + + val offset = if (shopData.face == "FRONT") Vector3f(0.25f, 0.25f, 0.0f) else Vector3f(-0.25f, 0.25f, 0.0f) + val rad = Math.toRadians(yaw.toDouble()) + offset.rotateY(-rad.toFloat()) + + val center = block.location.clone().add(0.5, 0.0, 0.5) + val targetPos = center.clone().add(offset.x.toDouble(), offset.y.toDouble(), offset.z.toDouble()) + + // Search for entity + val nearby = world.getNearbyEntities(targetPos, 0.2, 0.2, 0.2) + val display = nearby.filterIsInstance().firstOrNull() + + if (display != null) { + if (shopData.item != null) { + display.setItemStack(shopData.item) + } else { + display.setItemStack(ItemStack(Material.AIR)) + } + } + } +} + +object ShopGuiMap { + val openInvs = java.util.WeakHashMap() +} + +object ShopInputMap { + val pendingInputs = java.util.HashMap() +}