feat: ショップのアイテム編集
This commit is contained in:
parent
6b8937ae6b
commit
79d34ee2dc
|
|
@ -11,11 +11,12 @@ plugins {
|
||||||
}
|
}
|
||||||
|
|
||||||
repositories {
|
repositories {
|
||||||
|
|
||||||
mavenCentral()
|
mavenCentral()
|
||||||
maven("https://repo.papermc.io/repository/maven-public/")
|
maven("https://repo.papermc.io/repository/maven-public/")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
val exposedVersion = "1.0.0-rc-4"
|
||||||
|
|
||||||
dependencies {
|
dependencies {
|
||||||
compileOnly("io.papermc.paper:paper-api:1.21.10-R0.1-SNAPSHOT")
|
compileOnly("io.papermc.paper:paper-api:1.21.10-R0.1-SNAPSHOT")
|
||||||
compileOnly("org.jetbrains.kotlin:kotlin-stdlib")
|
compileOnly("org.jetbrains.kotlin:kotlin-stdlib")
|
||||||
|
|
@ -25,8 +26,17 @@ dependencies {
|
||||||
compileOnly("net.hareworks.hcu:economy:1.0")
|
compileOnly("net.hareworks.hcu:economy:1.0")
|
||||||
compileOnly("net.hareworks.hcu:faction:1.0")
|
compileOnly("net.hareworks.hcu:faction:1.0")
|
||||||
|
|
||||||
|
compileOnly("net.hareworks:kommand-lib")
|
||||||
|
compileOnly("net.hareworks:permits-lib")
|
||||||
|
|
||||||
// Libs
|
// Libs
|
||||||
compileOnly("com.michael-bull.kotlin-result:kotlin-result:2.1.0")
|
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 {
|
tasks {
|
||||||
|
|
|
||||||
|
|
@ -1,13 +1,74 @@
|
||||||
package net.hareworks.hcu.shop
|
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 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() {
|
class ShopPlugin : JavaPlugin() {
|
||||||
|
|
||||||
|
private var commands: KommandLib? = null
|
||||||
|
private val permits = PermitsLib.session(this)
|
||||||
|
|
||||||
override fun onEnable() {
|
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!")
|
logger.info("Shop plugin enabled!")
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onDisable() {
|
override fun onDisable() {
|
||||||
|
readinessTask?.cancel()
|
||||||
|
readinessTask = null
|
||||||
|
databaseReady = false
|
||||||
|
commands?.unregister()
|
||||||
|
commands = null
|
||||||
logger.info("Shop plugin disabled!")
|
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
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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 <tier>
|
||||||
|
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))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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 <T> transaction(block: JdbcTransaction.() -> T): T = DatabaseSessionManager.transaction(block)
|
||||||
|
}
|
||||||
|
|
@ -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]
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
37
src/main/kotlin/net/hareworks/hcu/shop/database/Shops.kt
Normal file
37
src/main/kotlin/net/hareworks/hcu/shop/database/Shops.kt
Normal file
|
|
@ -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
|
||||||
|
)
|
||||||
548
src/main/kotlin/net/hareworks/hcu/shop/listeners/ShopListener.kt
Normal file
548
src/main/kotlin/net/hareworks/hcu/shop/listeners/ShopListener.kt
Normal file
|
|
@ -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: <Item Name>
|
||||||
|
// L2: Buy: <Price>
|
||||||
|
// L3: Stock: <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<ItemDisplay>().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<Inventory, Int>()
|
||||||
|
}
|
||||||
|
|
||||||
|
object ShopInputMap {
|
||||||
|
val pendingInputs = java.util.HashMap<java.util.UUID, Int>()
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue
Block a user