package net.hareworks.ghostdisplays.display import java.time.Instant import java.util.UUID import java.util.concurrent.ConcurrentHashMap import java.util.logging.Logger import net.hareworks.ghostdisplays.api.DisplayService import net.hareworks.ghostdisplays.api.InteractionOptions import net.hareworks.ghostdisplays.api.audience.AudiencePredicates import net.hareworks.ghostdisplays.display.DisplayManager.DisplayOperationException import net.kyori.adventure.text.Component import net.kyori.adventure.text.minimessage.MiniMessage import net.kyori.adventure.text.serializer.plain.PlainTextComponentSerializer import org.bukkit.Bukkit import org.bukkit.Location import org.bukkit.block.data.BlockData import org.bukkit.entity.BlockDisplay import org.bukkit.entity.Display import org.bukkit.entity.ItemDisplay import org.bukkit.entity.Player import org.bukkit.entity.TextDisplay import org.bukkit.inventory.ItemStack import org.bukkit.plugin.java.JavaPlugin class DisplayManager( private val plugin: JavaPlugin, private val service: DisplayService, private val miniMessage: MiniMessage ) { private val displays = ConcurrentHashMap>() private val idPattern = Regex("^[a-z0-9_\\-]{1,32}$") private val logger: Logger = plugin.logger private val plainSerializer = PlainTextComponentSerializer.plainText() fun createTextDisplay(id: String, location: Location, initialContent: String, creator: Player?): ManagedDisplay.Text { val normalized = normalizeId(id) ensureIdAvailable(normalized) val safeLocation = location.clone() val controller = service.createTextDisplay(safeLocation, INTERACTION_DEFAULT) val component = parseMiniMessage(initialContent.ifBlank { "${normalized}" }) controller.applyEntityUpdate { textDisplay -> textDisplay.text(component) } val managed = ManagedDisplay.Text( id = normalized, controller = controller, location = safeLocation, createdAt = Instant.now(), createdBy = creator?.uniqueId, rawContent = initialContent, component = component ) displays[normalized] = managed creator?.let { controller.show(it) } return managed } fun createBlockDisplay(id: String, location: Location, blockData: BlockData, creator: Player?): ManagedDisplay.Block { val normalized = normalizeId(id) ensureIdAvailable(normalized) val safeLocation = location.clone() val controller = service.createBlockDisplay(safeLocation, INTERACTION_DEFAULT) controller.applyEntityUpdate { it.block = blockData.clone() } val managed = ManagedDisplay.Block( id = normalized, controller = controller, location = safeLocation, createdAt = Instant.now(), createdBy = creator?.uniqueId, blockData = blockData.clone() ) displays[normalized] = managed creator?.let { controller.show(it) } return managed } fun createItemDisplay(id: String, location: Location, itemStack: ItemStack, creator: Player?): ManagedDisplay.Item { val normalized = normalizeId(id) ensureIdAvailable(normalized) val safeLocation = location.clone() val controller = service.createItemDisplay(safeLocation, itemStack.clone(), INTERACTION_DEFAULT) controller.applyEntityUpdate { it.itemStack = itemStack.clone() } val managed = ManagedDisplay.Item( id = normalized, controller = controller, location = safeLocation, createdAt = Instant.now(), createdBy = creator?.uniqueId, itemStack = itemStack.clone() ) displays[normalized] = managed creator?.let { controller.show(it) } return managed } fun listDisplays(): List> = displays.values.sortedBy { it.id } fun findDisplay(id: String): ManagedDisplay<*>? = displays[normalizeId(id)] fun updateText(id: String, content: String): Component { val display = requireText(id) val component = parseMiniMessage(content) display.controller.applyEntityUpdate { it.text(component) } display.rawContent = content display.component = component return component } fun updateBlock(id: String, blockData: BlockData) { val display = requireBlock(id) display.controller.applyEntityUpdate { it.block = blockData.clone() } display.blockData = blockData.clone() } fun updateItem(id: String, itemStack: ItemStack) { val display = requireItem(id) val clone = itemStack.clone() display.controller.applyEntityUpdate { it.itemStack = clone } display.itemStack = clone } fun delete(id: String) { val normalized = normalizeId(id) val display = displays.remove(normalized) ?: throw DisplayOperationException("Display '$id' does not exist.") display.clearAudiences() display.controller.destroy() } fun showToPlayers(id: String, players: Collection) { val display = requireDisplay(id) players.forEach { display.controller.show(it) } } fun hideFromPlayers(id: String, players: Collection) { val display = requireDisplay(id) players.forEach { display.controller.hide(it) } } fun clearViewers(id: String) { val display = requireDisplay(id) val viewers = display.controller.viewerIds().mapNotNull { Bukkit.getPlayer(it) } viewers.forEach { display.controller.hide(it) } } fun addPermissionAudience(id: String, permission: String) { val display = requireDisplay(id) val key = "perm:${permission.lowercase()}" if (display.removeAudienceBinding(key)) { logger.info("Replacing permission audience '$permission' for $id") } val registration = display.controller.addAudience(AudiencePredicates.permission(permission)) display.registerAudienceBinding( AudienceBinding( key = key, description = "permission:$permission", registration = registration ) ) } fun removePermissionAudience(id: String, permission: String): Boolean { val display = requireDisplay(id) val key = "perm:${permission.lowercase()}" return display.removeAudienceBinding(key) } fun setNearAudience(id: String, radius: Double) { require(radius > 0) { "Radius must be positive." } val display = requireDisplay(id) val key = "near" display.removeAudienceBinding(key) val registration = display.controller.addAudience(AudiencePredicates.near(display.location, radius)) display.registerAudienceBinding( AudienceBinding( key = key, description = "radius:${String.format("%.2f", radius)}", registration = registration ) ) } fun clearAudiences(id: String) { val display = requireDisplay(id) display.clearAudiences() } fun destroyAll() { displays.values.forEach { runCatching { it.clearAudiences() } runCatching { it.controller.destroy() } } displays.clear() } fun describeContent(display: ManagedDisplay<*>): String = when (display) { is ManagedDisplay.Text -> plainSerializer.serialize(display.component) is ManagedDisplay.Block -> display.blockData.asString is ManagedDisplay.Item -> display.itemStack.type.name.lowercase() } private fun requireDisplay(id: String): ManagedDisplay<*> = displays[normalizeId(id)] ?: throw DisplayOperationException("Display '$id' does not exist.") private fun requireText(id: String): ManagedDisplay.Text = findDisplay(id) as? ManagedDisplay.Text ?: throw DisplayOperationException("Display '$id' is not a text display.") private fun requireBlock(id: String): ManagedDisplay.Block = findDisplay(id) as? ManagedDisplay.Block ?: throw DisplayOperationException("Display '$id' is not a block display.") private fun requireItem(id: String): ManagedDisplay.Item = findDisplay(id) as? ManagedDisplay.Item ?: throw DisplayOperationException("Display '$id' is not an item display.") private fun normalizeId(id: String): String = id.trim().lowercase() private fun ensureIdAvailable(id: String) { if (!idPattern.matches(id)) { throw DisplayOperationException("ID must match ${idPattern.pattern} and be 1-32 characters.") } if (displays.containsKey(id)) { throw DisplayOperationException("Display '$id' already exists.") } } private fun parseMiniMessage(raw: String): Component = runCatching { miniMessage.deserialize(if (raw.isBlank()) "empty" else raw) } .getOrElse { ex -> throw DisplayOperationException("MiniMessage parse error: ${ex.message ?: ex.javaClass.simpleName}") } companion object { private val INTERACTION_DEFAULT = InteractionOptions.enabled(width = 0.8, height = 0.8) } class DisplayOperationException(message: String) : RuntimeException(message) }