diff --git a/src/main/kotlin/net/hareworks/hcu/landsector/LandSectorPlugin.kt b/src/main/kotlin/net/hareworks/hcu/landsector/LandSectorPlugin.kt index f954370..efe142f 100644 --- a/src/main/kotlin/net/hareworks/hcu/landsector/LandSectorPlugin.kt +++ b/src/main/kotlin/net/hareworks/hcu/landsector/LandSectorPlugin.kt @@ -59,10 +59,16 @@ class LandSectorPlugin : JavaPlugin() { server.pluginManager.registerEvents(SectorListener(this, service, pIdService), this) + // Schedule auto-save every 5 minutes + server.scheduler.runTaskTimerAsynchronously(this, Runnable { + service.flushChanges() + }, 6000L, 6000L) + logger.info("LandSector initialized with services.") } override fun onDisable() { + sectorService?.flushChanges() logger.info("LandSector plugin has been disabled!") } } diff --git a/src/main/kotlin/net/hareworks/hcu/landsector/command/LandSectorCommand.kt b/src/main/kotlin/net/hareworks/hcu/landsector/command/LandSectorCommand.kt index 53898b3..3d9d27d 100644 --- a/src/main/kotlin/net/hareworks/hcu/landsector/command/LandSectorCommand.kt +++ b/src/main/kotlin/net/hareworks/hcu/landsector/command/LandSectorCommand.kt @@ -1,18 +1,22 @@ package net.hareworks.hcu.landsector.command import net.hareworks.hcu.landsector.LandSectorPlugin +import net.hareworks.hcu.landsector.model.Sector import net.hareworks.kommand_lib.kommand import net.kyori.adventure.text.Component import net.kyori.adventure.text.format.NamedTextColor +import org.bukkit.Bukkit +import org.bukkit.Location import org.bukkit.Material import org.bukkit.NamespacedKey import org.bukkit.entity.Player +import org.bukkit.entity.Shulker import org.bukkit.inventory.ItemStack import org.bukkit.persistence.PersistentDataType -class LandSectorCommand(private val plugin: LandSectorPlugin) { +class LandSectorCommand(private val landSectorPlugin: LandSectorPlugin) { fun register() { - kommand(plugin) { + kommand(landSectorPlugin) { command("landsector") { literal("give") { executes { @@ -22,7 +26,7 @@ class LandSectorCommand(private val plugin: LandSectorPlugin) { val meta = item.itemMeta meta.displayName(Component.text("Sector Core", NamedTextColor.LIGHT_PURPLE)) meta.persistentDataContainer.set( - NamespacedKey(plugin, "component"), + NamespacedKey(landSectorPlugin, "component"), PersistentDataType.STRING, "sector_core" ) @@ -32,6 +36,97 @@ class LandSectorCommand(private val plugin: LandSectorPlugin) { sender.sendMessage(Component.text("Gave 1 Sector Core.", NamedTextColor.GREEN)) } } + + literal("list") { + executes { + val player = sender as? Player ?: return@executes + val service = landSectorPlugin.sectorService + if (service == null) { + sender.sendMessage(Component.text("SectorService not ready.", NamedTextColor.RED)) + return@executes + } + + val worldName = player.world.name + val loc = player.location + + val sectors = service.getAllSectors(worldName) + .sortedBy { sector -> + val sx = sector.x + 0.5 + val sy = sector.y + 1.0 + val sz = sector.z + 0.5 + + (loc.x - sx) * (loc.x - sx) + + (loc.y - sy) * (loc.y - sy) + + (loc.z - sz) * (loc.z - sz) + } + + sender.sendMessage(Component.text("=== Sector List (${sectors.size}) ===", NamedTextColor.GOLD)) + sectors.forEach { sector -> + val distSq = (loc.x - (sector.x + 0.5)) * (loc.x - (sector.x + 0.5)) + + (loc.y - (sector.y + 1.0)) * (loc.y - (sector.y + 1.0)) + + (loc.z - (sector.z + 0.5)) * (loc.z - (sector.z + 0.5)) + val dist = Math.sqrt(distSq).toInt() + + sender.sendMessage( + Component.text() + .append(Component.text("#${sector.id} ", NamedTextColor.YELLOW)) + .append(Component.text("(${sector.x}, ${sector.y}, ${sector.z}) ", NamedTextColor.GRAY)) + .append(Component.text("HP: ${sector.hp} ", NamedTextColor.RED)) + .append(Component.text("- ${dist}m", NamedTextColor.AQUA)) + .build() + ) + } + } + } + + literal("delete") { + integer("id") { + executes { + val id = argument("id") + val service = landSectorPlugin.sectorService + if (service == null) { + sender.sendMessage(Component.text("SectorService not ready.", NamedTextColor.RED)) + return@executes + } + + val sector = service.deleteSector(id) + if (sector == null) { + sender.sendMessage(Component.text("Sector #$id not found.", NamedTextColor.RED)) + } else { + // Physical removal + val world = Bukkit.getWorld(sector.world) + if (world != null) { + val x = sector.x + val y = sector.y + val z = sector.z + + val blockBase = world.getBlockAt(x, y - 1, z) + val blockTop = world.getBlockAt(x, y + 1, z) + + if (blockBase.type == Material.BEDROCK) blockBase.type = Material.AIR + if (blockTop.type == Material.BEDROCK) blockTop.type = Material.AIR + + // Remove Entity + val center = Location(world, x + 0.5, y.toDouble(), z + 0.5) + if (center.chunk.isLoaded) { + val entities = world.getNearbyEntities(center, 0.5, 0.5, 0.5) + entities.forEach { entity -> + if (entity is Shulker) { + val key = NamespacedKey(landSectorPlugin, "sector_id") + val sId = entity.persistentDataContainer.get(key, PersistentDataType.INTEGER) + if (sId == id || sId == null) { + entity.remove() + } + } + } + } + } + + sender.sendMessage(Component.text("Sector #$id deleted.", NamedTextColor.GREEN)) + } + } + } + } } } } diff --git a/src/main/kotlin/net/hareworks/hcu/landsector/database/SectorsTable.kt b/src/main/kotlin/net/hareworks/hcu/landsector/database/SectorsTable.kt index c83179f..8e6f1a3 100644 --- a/src/main/kotlin/net/hareworks/hcu/landsector/database/SectorsTable.kt +++ b/src/main/kotlin/net/hareworks/hcu/landsector/database/SectorsTable.kt @@ -9,6 +9,7 @@ object SectorsTable : Table("land_sectors") { val x = integer("x") val y = integer("y") val z = integer("z") + val hp = integer("hp").default(1000) override val primaryKey = PrimaryKey(id) } diff --git a/src/main/kotlin/net/hareworks/hcu/landsector/listener/SectorListener.kt b/src/main/kotlin/net/hareworks/hcu/landsector/listener/SectorListener.kt index 9da20a2..937f77b 100644 --- a/src/main/kotlin/net/hareworks/hcu/landsector/listener/SectorListener.kt +++ b/src/main/kotlin/net/hareworks/hcu/landsector/listener/SectorListener.kt @@ -7,13 +7,24 @@ import net.kyori.adventure.text.Component import net.kyori.adventure.text.format.NamedTextColor import org.bukkit.Material import org.bukkit.NamespacedKey +import org.bukkit.Particle +import org.bukkit.Sound import org.bukkit.entity.EntityType import org.bukkit.entity.Shulker import org.bukkit.attribute.Attribute import org.bukkit.event.EventHandler import org.bukkit.event.Listener import org.bukkit.event.block.BlockPlaceEvent +import org.bukkit.event.entity.EntityDamageEvent +import org.bukkit.event.entity.EntityDamageByEntityEvent +import org.bukkit.event.world.ChunkLoadEvent +import org.bukkit.entity.Player +import org.bukkit.entity.BlockDisplay import org.bukkit.persistence.PersistentDataType +import org.bukkit.util.Transformation +import org.joml.Vector3f +import org.joml.Quaternionf +import org.bukkit.block.data.type.Slab class SectorListener( private val plugin: LandSectorPlugin, @@ -52,29 +63,249 @@ class SectorListener( return } - // Spawn Shulker - val shulkerLoc = loc.clone().add(0.5, 1.0, 0.5) - val shulker = player.world.spawnEntity(shulkerLoc, EntityType.SHULKER) as Shulker - shulker.setAI(false) - shulker.isInvulnerable = true - shulker.isInvisible = true - - val param = shulker.getAttribute(Attribute.MAX_HEALTH) - param?.baseValue = 1000.0 - shulker.health = 1000.0 - - // Place top bedrock - above2.type = Material.BEDROCK - - // Record to DB (using Shulker pos as center) - sectorService.createSector( + // Record to DB first to get ID + val sector = sectorService.createSector( playerEntry.actorId, player.world.name, above1.x, above1.y, above1.z ) + + if (sector == null) { + player.sendMessage(Component.text("Failed to create sector record.", NamedTextColor.RED)) + event.isCancelled = true + return + } + + // Create Visuals + val sectorKey = NamespacedKey(plugin, "sector_id") + val locCenter = loc.clone().add(0.5, 0.0, 0.5) + + // 1. Command Block + val cbLoc = locCenter.clone().add(0.0, 1.5, 0.0) + val cb = player.world.spawnEntity(cbLoc, EntityType.BLOCK_DISPLAY) as BlockDisplay + cb.block = Material.COMMAND_BLOCK.createBlockData { + (it as org.bukkit.block.data.Directional).facing = org.bukkit.block.BlockFace.UP + } + cb.transformation = Transformation( + Vector3f(-0.25f, -0.25f, -0.25f), + Quaternionf(0f, 0f, 0f, 1f), + Vector3f(0.5f, 0.5f, 0.5f), + Quaternionf(0f, 0f, 0f, 1f) + ) + cb.persistentDataContainer.set(sectorKey, PersistentDataType.INTEGER, sector.id) + + // 2. Tinted Glass + val glass = player.world.spawnEntity(cbLoc, EntityType.BLOCK_DISPLAY) as BlockDisplay + glass.block = Material.TINTED_GLASS.createBlockData() + glass.transformation = Transformation( + Vector3f(-0.4375f, -0.4375f, -0.4375f), + Quaternionf(0f, 0f, 0f, 1f), + Vector3f(0.875f, 0.875f, 0.875f), + Quaternionf(0f, 0f, 0f, 1f) + ) + glass.persistentDataContainer.set(sectorKey, PersistentDataType.INTEGER, sector.id) + + // 3. Top Cauldron + val topCauldronLoc = locCenter.clone().add(0.0, 2.3125, 0.0) + val topCauldron = player.world.spawnEntity(topCauldronLoc, EntityType.BLOCK_DISPLAY) as BlockDisplay + topCauldron.block = Material.CAULDRON.createBlockData() + topCauldron.transformation = Transformation( + Vector3f(0.4990f, -0.4990f, 0.4990f), + Quaternionf(0f, 1f, 0f, 0f), + Vector3f(0.9980f, 0.9980f, 0.9980f), + Quaternionf(0f, 0f, 0f, 1f) + ) + topCauldron.persistentDataContainer.set(sectorKey, PersistentDataType.INTEGER, sector.id) + + // 4. Bottom Cauldron + val botCauldronLoc = locCenter.clone().add(0.0, 0.6875, 0.0) + val botCauldron = player.world.spawnEntity(botCauldronLoc, EntityType.BLOCK_DISPLAY) as BlockDisplay + botCauldron.block = Material.CAULDRON.createBlockData() + botCauldron.transformation = Transformation( + Vector3f(-0.4990f, 0.4990f, 0.4990f), + Quaternionf(1f, 0f, 0f, 0f), + Vector3f(0.9980f, 0.9980f, 0.9980f), + Quaternionf(0f, 0f, 0f, 1f) + ) + botCauldron.persistentDataContainer.set(sectorKey, PersistentDataType.INTEGER, sector.id) + + // Spawn Shulker (Hitbox) + val shulkerLoc = loc.clone().add(0.5, 1.0, 0.5) // Y+1, sits in space 1-2. + val shulker = player.world.spawnEntity(shulkerLoc, EntityType.SHULKER) as Shulker + shulker.setAI(false) + shulker.isInvisible = true + // shulker.isInvulnerable = true // REMOVED to allow damage + + val param = shulker.getAttribute(Attribute.MAX_HEALTH) + param?.baseValue = 1000.0 + shulker.health = 1000.0 + shulker.maximumNoDamageTicks = 0 + + // Tag Shulker with Sector ID + shulker.persistentDataContainer.set(sectorKey, PersistentDataType.INTEGER, sector.id) + + // Place Blocks + block.type = Material.DEEPSLATE_TILE_SLAB + val bottomSlab = block.blockData as Slab + bottomSlab.type = Slab.Type.BOTTOM + block.blockData = bottomSlab + + above2.type = Material.DEEPSLATE_TILE_SLAB + val topSlab = above2.blockData as Slab + topSlab.type = Slab.Type.TOP + above2.blockData = topSlab player.sendMessage(Component.text("Sector Core placed!", NamedTextColor.GREEN)) } + + @EventHandler + fun onDamage(event: EntityDamageEvent) { + val entity = event.entity + if (entity !is Shulker) return + + val sectorKey = NamespacedKey(plugin, "sector_id") + if (!entity.persistentDataContainer.has(sectorKey, PersistentDataType.INTEGER)) return + + val sectorId = entity.persistentDataContainer.get(sectorKey, PersistentDataType.INTEGER) ?: return + + // Prevent vanilla death logic but show damage effect + // We restore health to max after processing + val damage = event.finalDamage + val maxHealth = entity.getAttribute(Attribute.MAX_HEALTH)?.value ?: 1000.0 + + val newHp = sectorService.reduceHealth(sectorId, damage.toInt()) + + // Cancel the event so it doesn't actually die in vanilla terms, + // OR let it happen but force health reset. + // Cancelling prevents the red flash in some versions/cases, so setting damage to 0 is often better + // if we want the effect. But resetting health is robust. + // Let's reset health. + entity.health = maxHealth + entity.noDamageTicks = 0 + + if (newHp != null) { + val loc = entity.location + val world = entity.world + + // Effects + world.playSound(loc, Sound.BLOCK_ANVIL_PLACE, 1.0f, 0.5f) // Heavy metallic sound + world.spawnParticle(Particle.BLOCK, loc.add(0.0, 0.5, 0.0), 20, 0.3, 0.3, 0.3, Material.BEDROCK.createBlockData()) + + // Action Bar Display + if (event is EntityDamageByEntityEvent && event.damager is Player) { + val player = event.damager as Player + val percent = newHp.toDouble() / maxHealth.toDouble() + val color = when { + percent > 0.5 -> NamedTextColor.GREEN + percent > 0.2 -> NamedTextColor.YELLOW + else -> NamedTextColor.RED + } + + val progressBar = createProgressBar(newHp, maxHealth.toInt(), color) + + val message = Component.text() + .append(Component.text("Sector Core ", NamedTextColor.GOLD)) + .append(progressBar) + .append(Component.text(" ")) + .append(Component.text(newHp, color)) + .append(Component.text("/", NamedTextColor.GRAY)) + .append(Component.text(maxHealth.toInt(), NamedTextColor.GRAY)) + .build() + + player.sendActionBar(message) + } + + if (newHp <= 0) { + // Destruction + entity.remove() + + // Remove other visuals in chunk + val chunk = entity.chunk + chunk.entities.forEach { ent -> + val sId = ent.persistentDataContainer.get(sectorKey, PersistentDataType.INTEGER) + if (sId == sectorId) ent.remove() + } + + // Remove blocks. + // We know Shulker is at x, y, z. + // Base is y-1, Top is y+1 + val loc = entity.location + val world = entity.world + val x = loc.blockX + val y = loc.blockY + val z = loc.blockZ + + val base = world.getBlockAt(x, y - 1, z) + val top = world.getBlockAt(x, y + 1, z) + + if (base.type == Material.DEEPSLATE_TILE_SLAB) base.type = Material.AIR + if (top.type == Material.DEEPSLATE_TILE_SLAB) top.type = Material.AIR + + world.dropItemNaturally(loc, org.bukkit.inventory.ItemStack(Material.DEEPSLATE_TILE_SLAB, 2)) + + // Maybe delete from DB or mark as destroyed? + // For now, valid destruction. + } + } + } + + private fun createProgressBar(current: Int, max: Int, color: NamedTextColor): Component { + val totalBars = 20 + val percent = current.toDouble() / max.toDouble() + val filledBars = (totalBars * percent).toInt().coerceIn(0, totalBars) + val emptyBars = totalBars - filledBars + + return Component.text() + .append(Component.text("[", NamedTextColor.DARK_GRAY)) + .append(Component.text("|".repeat(filledBars), color)) + .append(Component.text("|".repeat(emptyBars), NamedTextColor.GRAY)) + .append(Component.text("]", NamedTextColor.DARK_GRAY)) + .build() + } + + @EventHandler + fun onChunkLoad(event: ChunkLoadEvent) { + if (event.isNewChunk) return // Optimization: New chunks won't have old sectors to cleanup + + val chunk = event.chunk + val entities = chunk.entities + val sectorKey = NamespacedKey(plugin, "sector_id") + + entities.forEach { entity -> + if (entity.persistentDataContainer.has(sectorKey, PersistentDataType.INTEGER)) { + val sectorId = entity.persistentDataContainer.get(sectorKey, PersistentDataType.INTEGER) ?: return@forEach + + // Check existance async + plugin.server.scheduler.runTaskAsynchronously(plugin, Runnable { + val exists = sectorService.exists(sectorId) + if (!exists) { + plugin.server.scheduler.runTask(plugin, Runnable { + // Re-verify ent validity + if (entity.isValid) { + entity.remove() + // We should also clean up blocks, but that requires knowing where they are relative to entity + val loc = entity.location + val world = entity.world + val x = loc.blockX + val y = loc.blockY + val z = loc.blockZ + + // Entity is at x, y, z + // Base is y-1, Top is y+1 + val base = world.getBlockAt(x, y - 1, z) + val top = world.getBlockAt(x, y + 1, z) + + if (base.type == Material.DEEPSLATE_TILE_SLAB) base.type = Material.AIR + if (top.type == Material.DEEPSLATE_TILE_SLAB) top.type = Material.AIR + + plugin.logger.info("Removed orphaned sector artifacts for ID $sectorId at $x,$y,$z") + } + }) + } + }) + } + } + } } diff --git a/src/main/kotlin/net/hareworks/hcu/landsector/model/Sector.kt b/src/main/kotlin/net/hareworks/hcu/landsector/model/Sector.kt index 9c6fb83..053efad 100644 --- a/src/main/kotlin/net/hareworks/hcu/landsector/model/Sector.kt +++ b/src/main/kotlin/net/hareworks/hcu/landsector/model/Sector.kt @@ -6,5 +6,6 @@ data class Sector( val world: String, val x: Int, val y: Int, - val z: Int + val z: Int, + val hp: Int ) diff --git a/src/main/kotlin/net/hareworks/hcu/landsector/service/SectorService.kt b/src/main/kotlin/net/hareworks/hcu/landsector/service/SectorService.kt index 3cdd888..a48b5d1 100644 --- a/src/main/kotlin/net/hareworks/hcu/landsector/service/SectorService.kt +++ b/src/main/kotlin/net/hareworks/hcu/landsector/service/SectorService.kt @@ -7,11 +7,18 @@ import org.jetbrains.exposed.v1.jdbc.Database import org.jetbrains.exposed.v1.jdbc.SchemaUtils import org.jetbrains.exposed.v1.jdbc.insert import org.jetbrains.exposed.v1.jdbc.selectAll +import org.jetbrains.exposed.v1.jdbc.select +import org.jetbrains.exposed.v1.jdbc.update import org.jetbrains.exposed.v1.jdbc.andWhere +import org.jetbrains.exposed.v1.jdbc.deleteWhere import org.jetbrains.exposed.v1.jdbc.transactions.transaction +import java.util.concurrent.ConcurrentHashMap class SectorService(private val database: Database) { + private val hpCache = ConcurrentHashMap() + private val dirtySet = ConcurrentHashMap.newKeySet() + fun init() { transaction(database) { SchemaUtils.createMissingTablesAndColumns(SectorsTable) @@ -26,9 +33,11 @@ class SectorService(private val database: Database) { it[SectorsTable.x] = x it[SectorsTable.y] = y it[SectorsTable.z] = z + it[SectorsTable.hp] = 1000 }[SectorsTable.id] - Sector(id, ownerActorId, world, x, y, z) + hpCache[id] = 1000 + Sector(id, ownerActorId, world, x, y, z, 1000) } } @@ -46,9 +55,106 @@ class SectorService(private val database: Database) { it[SectorsTable.world], it[SectorsTable.x], it[SectorsTable.y], - it[SectorsTable.z] + it[SectorsTable.z], + it[SectorsTable.hp] ) }.singleOrNull() } } + + fun reduceHealth(id: Int, amount: Int): Int? { + // Try get from cache + var currentHp = hpCache[id] + + // If not in cache, load from DB + if (currentHp == null) { + val sector = transaction(database) { + SectorsTable.selectAll().andWhere { SectorsTable.id eq id }.singleOrNull() + } ?: return null // Not found + + currentHp = sector[SectorsTable.hp] + hpCache[id] = currentHp + } + + val newHp = (currentHp!! - amount).coerceAtLeast(0) + hpCache[id] = newHp + dirtySet.add(id) + + return newHp + } + + fun flushChanges() { + if (dirtySet.isEmpty()) return + + // Take a snapshot of keys to update + val toUpdate = dirtySet.toMutableSet() + dirtySet.removeAll(toUpdate) // Clear them from dirty set so we don't re-save unless modified again + + if (toUpdate.isEmpty()) return + + transaction(database) { + toUpdate.forEach { id -> + val hp = hpCache[id] ?: return@forEach + SectorsTable.update({ SectorsTable.id eq id }) { + it[SectorsTable.hp] = hp + } + } + } + } + + fun deleteSector(id: Int): Sector? { + val sector = transaction(database) { + val record = SectorsTable.selectAll().andWhere { SectorsTable.id eq id }.singleOrNull() ?: return@transaction null + + SectorsTable.deleteWhere { SectorsTable.id eq id } + + Sector( + record[SectorsTable.id], + record[SectorsTable.ownerActorId], + record[SectorsTable.world], + record[SectorsTable.x], + record[SectorsTable.y], + record[SectorsTable.z], + record[SectorsTable.hp] + ) + } + + if (sector != null) { + hpCache.remove(id) + dirtySet.remove(id) + } + + return sector + } + + fun exists(id: Int): Boolean { + if (hpCache.containsKey(id)) return true + + return transaction(database) { + SectorsTable.selectAll().andWhere { SectorsTable.id eq id }.count() > 0 + } + } + + fun getAllSectors(world: String): List { + return transaction(database) { + SectorsTable.selectAll() + .andWhere { SectorsTable.world eq world } + .map { + // Update HP from cache if exists + val id = it[SectorsTable.id] + val cachedHp = hpCache[id] + val hp = cachedHp ?: it[SectorsTable.hp] + + Sector( + id, + it[SectorsTable.ownerActorId], + it[SectorsTable.world], + it[SectorsTable.x], + it[SectorsTable.y], + it[SectorsTable.z], + hp + ) + } + } + } }