diff --git a/docs/plans/chunk-claims/11-zakup-chunka-i-gui.md b/docs/plans/chunk-claims/11-zakup-chunka-i-gui.md index 55ca9d0..a081f6c 100644 --- a/docs/plans/chunk-claims/11-zakup-chunka-i-gui.md +++ b/docs/plans/chunk-claims/11-zakup-chunka-i-gui.md @@ -61,3 +61,18 @@ Dodano 12 testów `ChunkPurchaseServiceTest` na H2 z kontrolowanym dostawcą i o Dwa nowe testy wskazanego `PlotCacheLoaderTest` sprawdzają rzeczywisty licznik w pełnym i pojedynczym odczycie oraz odrzucenie ujemnej wartości. Dotychczasowa regresja SQLite/H2, geometrii, API i backupów przechodzi. Nie wykonano w tej iteracji testu GUI z klientem Minecraft, realnego Vault/VaultUnlocked/WorldGuard ani nowego przebiegu Paper/Folia i zewnętrznej macierzy SQL. Kontrolowany executor testowy nie zastępuje tych prób. Kolejny zakres: test akceptacyjny zakupu na Paper z dostawcą, natywne dialogi, wewnętrzna obsługa stanów niepewnych i pozostała macierz E6–E8. + + +## Ciągła obsługa GUI i usuwanie chunków + +Po udanym zakupie chunka `ChunkExpandGUI` pozostaje otwarte. Cache, geometria, licznik chunków +oraz cena kolejnego poziomu są odświeżane po zakończeniu transakcji, więc gracz może od razu +wybrać następny przylegający chunk bez ponownego otwierania panelu. Przycisk potwierdzenia jest +blokowany na czas trwania operacji, aby nie wysłać dwóch zakupów tej samej oferty. + +Działki chunkowe mają osobne `ChunkRemoveGUI` dostępne z panelu działki. Usunięcie dotyczy +dokładnie jednego chunka i zwiększa `geometry_revision` w tej samej transakcji co wpis historii. +Nie można usunąć ostatniego chunka, bazowego chunka zawierającego zapisane `x/z` działki ani +chunka, którego usunięcie rozdzieliłoby geometrię na niepołączone wyspy. Operacja nie zwraca +kosztu zakupu i nie obniża `plot_expansion_levels`, dzięki czemu usuwanie terenu nie resetuje +progresywnego cennika rozszerzeń. diff --git a/src/main/kotlin/pl/syntaxdevteam/plotsx/databases/ChunkRemovalTransaction.kt b/src/main/kotlin/pl/syntaxdevteam/plotsx/databases/ChunkRemovalTransaction.kt new file mode 100644 index 0000000..5bb3e0b --- /dev/null +++ b/src/main/kotlin/pl/syntaxdevteam/plotsx/databases/ChunkRemovalTransaction.kt @@ -0,0 +1,96 @@ +package pl.syntaxdevteam.plotsx.databases + +import pl.syntaxdevteam.plotsx.geometry.ChunkGeometry +import pl.syntaxdevteam.plotsx.geometry.ChunkPosition +import java.sql.Connection +import java.util.UUID + +/** + * Removes one chunk from a chunk plot. The operation never refunds money and never decreases + * plot_expansion_levels, so removing land cannot be used to reset progressive expansion pricing. + */ +internal object ChunkRemovalTransaction { + data class Request( + val plotId: Int, + val owner: UUID, + val actor: UUID, + val target: ChunkPosition, + val expectedRevision: Long + ) + + sealed interface Result { + data class Success(val chunk: ChunkPosition, val revision: Long) : Result + data object PlotNotFound : Result + data object NotOwner : Result + data object WrongGeometry : Result + data object StaleQuote : Result + data object ChunkNotFound : Result + data object LastChunk : Result + data object AnchorChunk : Result + data object WouldDisconnect : Result + } + + fun remove(conn: Connection, request: Request): Result { + require(conn.autoCommit) { "Chunk removal requires a dedicated connection" } + val isolation = conn.transactionIsolation + try { + conn.transactionIsolation = Connection.TRANSACTION_SERIALIZABLE + conn.autoCommit = false + val result = applyInTransaction(conn, request) + if (result is Result.Success) conn.commit() else conn.rollback() + return result + } catch (failure: Exception) { + try { conn.rollback() } catch (rollback: Exception) { failure.addSuppressed(rollback) } + throw failure + } finally { + conn.autoCommit = true + if (conn.transactionIsolation != isolation) conn.transactionIsolation = isolation + } + } + + fun applyInTransaction(conn: Connection, request: Request): Result { + require(!conn.autoCommit) { "An existing transaction is required" } + val plot = PlotRepository.readAll(conn).singleOrNull { it.id == request.plotId } + ?: return Result.PlotNotFound + if (plot.ownerUuid != request.owner) return Result.NotOwner + val geometry = plot.geometry as? ChunkGeometry ?: return Result.WrongGeometry + if (request.expectedRevision < 0 || plot.geometryRevision != request.expectedRevision || + plot.geometryRevision == Long.MAX_VALUE) return Result.StaleQuote + if (request.target !in geometry.chunks) return Result.ChunkNotFound + if (geometry.chunks.size <= 1) return Result.LastChunk + + val anchor = ChunkPosition.atBlock(plot.x, plot.z) + if (request.target == anchor) return Result.AnchorChunk + if (geometry.without(request.target) == null) return Result.WouldDisconnect + + val nextRevision = plot.geometryRevision + 1 + conn.prepareStatement(""" + UPDATE plots SET geometry_revision = ? + WHERE plot_id = ? AND owner_uuid = ? AND geometry_type = 'chunks' AND geometry_revision = ? + """.trimIndent()).use { + it.setLong(1, nextRevision) + it.setInt(2, plot.id) + it.setString(3, request.owner.toString()) + it.setLong(4, request.expectedRevision) + if (it.executeUpdate() != 1) return Result.StaleQuote + } + conn.prepareStatement(""" + DELETE FROM plot_chunks + WHERE plot_id = ? AND world_key = ? AND chunk_x = ? AND chunk_z = ? + """.trimIndent()).use { + it.setInt(1, plot.id) + it.setString(2, PlotGeometryRepository.worldKey(plot.world)) + it.setInt(3, request.target.x) + it.setInt(4, request.target.z) + if (it.executeUpdate() != 1) return Result.StaleQuote + } + conn.prepareStatement("INSERT INTO plot_logs (plot_id, action, actor_uuid, timestamp) VALUES (?, ?, ?, ?)").use { + it.setInt(1, plot.id) + it.setString(2, "REMOVE_CHUNK:${request.target.x},${request.target.z}:$nextRevision") + it.setString(3, request.actor.toString()) + it.setLong(4, System.currentTimeMillis()) + it.executeUpdate() + } + return Result.Success(request.target, nextRevision) + } +} diff --git a/src/main/kotlin/pl/syntaxdevteam/plotsx/databases/DatabaseHandler.kt b/src/main/kotlin/pl/syntaxdevteam/plotsx/databases/DatabaseHandler.kt index ed42c3e..161588f 100644 --- a/src/main/kotlin/pl/syntaxdevteam/plotsx/databases/DatabaseHandler.kt +++ b/src/main/kotlin/pl/syntaxdevteam/plotsx/databases/DatabaseHandler.kt @@ -377,6 +377,15 @@ class DatabaseHandler(private val plugin: PlotsX) { } } + /** Removes exactly one chunk while publishing the new protection snapshot atomically. */ + internal fun removeChunkAtomically( + request: ChunkRemovalTransaction.Request + ): ChunkRemovalTransaction.Result = protectedMutation { + (getConnection() ?: error("No database connection for chunk removal")).use { + ChunkRemovalTransaction.remove(it, request) + } + } + fun expandPlotAtomically( plotId: Int, ownerUuid: UUID, diff --git a/src/main/kotlin/pl/syntaxdevteam/plotsx/geometry/ChunkGeometry.kt b/src/main/kotlin/pl/syntaxdevteam/plotsx/geometry/ChunkGeometry.kt index 4e1d667..4380559 100644 --- a/src/main/kotlin/pl/syntaxdevteam/plotsx/geometry/ChunkGeometry.kt +++ b/src/main/kotlin/pl/syntaxdevteam/plotsx/geometry/ChunkGeometry.kt @@ -86,4 +86,13 @@ class ChunkGeometry(chunks: Collection) : PlotGeometry { } return null } + + /** + * Returns the geometry after removing [target], or null when the target is not owned, + * is the last chunk, or its removal would split the plot into disconnected islands. + */ + fun without(target: ChunkPosition): ChunkGeometry? { + if (target !in chunks || chunks.size <= 1) return null + return runCatching { ChunkGeometry(chunks - target) }.getOrNull() + } } diff --git a/src/main/kotlin/pl/syntaxdevteam/plotsx/gui/ChunkExpandGUI.kt b/src/main/kotlin/pl/syntaxdevteam/plotsx/gui/ChunkExpandGUI.kt index 732cb9f..5111b2d 100644 --- a/src/main/kotlin/pl/syntaxdevteam/plotsx/gui/ChunkExpandGUI.kt +++ b/src/main/kotlin/pl/syntaxdevteam/plotsx/gui/ChunkExpandGUI.kt @@ -17,7 +17,7 @@ import java.util.UUID internal class ChunkExpandGUI( private val plugin: PlotsX, private val plotId: Int, - private val requestedTarget: ChunkPosition? = null + private var requestedTarget: ChunkPosition? = null ) : AbstractGUI( plugin.messageHandler.stringMessageToComponentNoPrefix("GUI", "expand.title"), 54 ) { @@ -48,9 +48,10 @@ internal class ChunkExpandGUI( offered = plot val standing = if (player.world.name == plot.world) ChunkPosition.atBlock(player.location.blockX, player.location.blockZ) else null - if (viewCenter == null) viewCenter = requestedTarget ?: standing?.takeIf { it in geometry.chunks } ?: geometry.chunks.first() - if (requestedTarget != null) { - val expansion = geometry.expansionTo(requestedTarget) ?: return null + val fixedRequestedTarget = requestedTarget + if (viewCenter == null) viewCenter = fixedRequestedTarget ?: standing?.takeIf { it in geometry.chunks } ?: geometry.chunks.first() + if (fixedRequestedTarget != null) { + val expansion = geometry.expansionTo(fixedRequestedTarget) ?: return null source = expansion.source direction = expansion.direction target = expansion.target @@ -138,16 +139,21 @@ inventory.setItem(cancelIndex, item(Material.BARRIER, "expand.cancel")) plugin.cacheManager.getPlot(plotId)?.let { Helpers(plugin).visualizePlotBorder3D(player, it, Helpers(plugin).borderDurationSeconds(), 2, 4) } return } - if (event.rawSlot !in setOf(confirmIndex, cancelIndex)) return - if (event.rawSlot == confirmIndex && target == null) { player.sendMessage(text("expand.map_choose")); return } - if (event.rawSlot == confirmIndex && !refreshSelectedOffer(player)) return + if (event.rawSlot == cancelIndex) { + submitted = true + player.scheduler.run(plugin, { + if (player.openInventory.topInventory !== inventory) return@run + player.closeInventory() + plugin.guiHandler.unregisterGui(player) + }, null) + return + } + if (event.rawSlot != confirmIndex) return + if (target == null) { player.sendMessage(text("expand.map_choose")); return } + if (!refreshSelectedOffer(player)) return submitted = true - val confirm = event.rawSlot == confirmIndex - player.scheduler.run(plugin, { - if (player.openInventory.topInventory !== inventory) return@run - player.closeInventory(); plugin.guiHandler.unregisterGui(player) - if (confirm) submit(player) - }, null) + inventory.setItem(confirmIndex, item(Material.CLOCK, "expand.processing")) + submit(player) } /** Refreshes the quote immediately before closing the GUI, so map navigation cannot submit stale data. */ @@ -222,13 +228,25 @@ inventory.setItem(cancelIndex, item(Material.BARRIER, "expand.cancel")) } if (plugin.isEnabled) plugin.server.scheduler.runTask(plugin, Runnable { if (!player.isOnline) return@Runnable + submitted = false when (result) { ChunkPurchaseService.Result.SUCCESS -> { plugin.logger.debug( "Chunk purchase completed: operation=${operation.id}, player=${player.name}, " + "plot=$plotId, target=${to.x},${to.z}" ) - plugin.cacheManager.getPlot(plotId)?.let { Helpers(plugin).visualizePlotBorder3D(player, it, Helpers(plugin).borderDurationSeconds(), 2, 4) } + requestedTarget = null + target = null + source = null + viewCenter = to + player.sendMessage(plugin.messageHandler.stringMessageToComponent( + "plots", "chunk_expand_success", mapOf("x" to to.x.toString(), "z" to to.z.toString()))) + plugin.cacheManager.getPlot(plotId)?.let { current -> + Helpers(plugin).visualizePlotBorder3D(player, current, Helpers(plugin).borderDurationSeconds(), 2, 4) + if (player.openInventory.topInventory === inventory) { + prepareOffer(player)?.let { renderInventory(player, it) } + } + } } ChunkPurchaseService.Result.AREA_LIMIT -> error(player, "expand_area_limit") ChunkPurchaseService.Result.CHUNK_LIMIT -> error(player, "claim_chunk_limit") @@ -240,6 +258,14 @@ inventory.setItem(cancelIndex, item(Material.BARRIER, "expand.cancel")) ChunkPurchaseService.Result.REVIEW_REQUIRED -> player.sendMessage(plugin.messageHandler.stringMessageToComponent( "error", "chunk_purchase_review", mapOf("operation" to operation.id.toString()))) } + if (result != ChunkPurchaseService.Result.SUCCESS && player.openInventory.topInventory === inventory) { + val current = plugin.cacheManager.getPlot(plotId) + if (current != null) { + offered = current + price = ExpansionEconomy.chunkPrice(plugin, current.expansionLevel) + renderInventory(player, current) + } + } }) }) } diff --git a/src/main/kotlin/pl/syntaxdevteam/plotsx/gui/ChunkRemoveGUI.kt b/src/main/kotlin/pl/syntaxdevteam/plotsx/gui/ChunkRemoveGUI.kt new file mode 100644 index 0000000..65c7c89 --- /dev/null +++ b/src/main/kotlin/pl/syntaxdevteam/plotsx/gui/ChunkRemoveGUI.kt @@ -0,0 +1,300 @@ +package pl.syntaxdevteam.plotsx.gui + +import org.bukkit.Material +import org.bukkit.entity.Player +import org.bukkit.event.inventory.InventoryClickEvent +import org.bukkit.inventory.ItemStack +import pl.syntaxdevteam.plotsx.PlotsX +import pl.syntaxdevteam.plotsx.databases.ChunkRemovalTransaction +import pl.syntaxdevteam.plotsx.databases.ExpansionDirection +import pl.syntaxdevteam.plotsx.databases.Helpers +import pl.syntaxdevteam.plotsx.databases.PlotData +import pl.syntaxdevteam.plotsx.geometry.ChunkGeometry +import pl.syntaxdevteam.plotsx.geometry.ChunkPosition +import pl.syntaxdevteam.plotsx.permissions.PermissionChecker + +internal class ChunkRemoveGUI( + private val plugin: PlotsX, + private val plotId: Int +) : AbstractGUI( + plugin.messageHandler.stringMessageToComponentNoPrefix("GUI", "remove_chunk.title"), 54 +) { + private val panButtons = mapOf( + 4 to ExpansionDirection.NORTH, + 26 to ExpansionDirection.EAST, + 49 to ExpansionDirection.SOUTH, + 18 to ExpansionDirection.WEST + ) + private val mapSlots = (1..4).flatMap { row -> (1..7).map { column -> row * 9 + column } } + private val bordersIndex = 45 + private val confirmIndex = 47 + private val cancelIndex = 51 + + private var offered: PlotData? = null + private var target: ChunkPosition? = null + private var viewCenter: ChunkPosition? = null + private var submitted = false + + override fun open(player: Player) { + val plot = prepare(player) ?: run { + plugin.guiHandler.unregisterGui(player) + return + } + renderInventory(player, plot) + plugin.guiHandler.openLegacy(player, this) + } + + private fun prepare(player: Player): PlotData? { + val plot = plugin.cacheManager.getPlot(plotId) ?: run { + error(player, "plot_not_found") + return null + } + val geometry = plot.geometry as? ChunkGeometry ?: run { + error(player, "chunk_remove_wrong_geometry") + return null + } + if (plot.ownerUuid != player.uniqueId) { + error(player, "not_owner") + return null + } + if (!PermissionChecker.canExpandPlot(player)) { + error(player, "no_permission") + return null + } + offered = plot + val standing = if (player.world.name == plot.world) + ChunkPosition.atBlock(player.location.blockX, player.location.blockZ) else null + if (viewCenter == null) viewCenter = standing?.takeIf { it in geometry.chunks } ?: anchor(plot) + target = target?.takeIf { isRemovable(plot, geometry, it) } + return plot + } + + private fun renderInventory(player: Player, plot: PlotData) { + inventory.clear() + val geometry = plot.geometry as ChunkGeometry + val center = viewCenter ?: anchor(plot) + val standing = if (player.world.name == plot.world) + ChunkPosition.atBlock(player.location.blockX, player.location.blockZ) else null + + mapSlots.forEach { slot -> + val row = slot / 9 + val column = slot % 9 + val position = offset(center, column - 4, row - 3) ?: return@forEach + val owned = position in geometry.chunks + val removable = owned && isRemovable(plot, geometry, position) + val material = when { + position == target -> Material.RED_CONCRETE + position == anchor(plot) -> Material.GOLD_BLOCK + owned && position == standing -> Material.PLAYER_HEAD + removable -> Material.ORANGE_STAINED_GLASS_PANE + owned -> Material.RED_STAINED_GLASS_PANE + else -> Material.GRAY_STAINED_GLASS_PANE + } + val key = when { + position == target -> "remove_chunk.map_selected" + position == anchor(plot) -> "remove_chunk.map_anchor" + owned && position == standing -> "remove_chunk.map_player" + removable -> "remove_chunk.map_removable" + owned -> "remove_chunk.map_locked" + else -> "remove_chunk.map_empty" + } + inventory.setItem(slot, mapItem(material, key, position)) + } + + panButtons.forEach { (slot, direction) -> + inventory.setItem(slot, item(Material.ARROW, "remove_chunk.map_pan_${direction.name.lowercase()}")) + } + inventory.setItem(bordersIndex, item(Material.ENDER_EYE, "remove_chunk.show_borders")) + val lore = listOf( + target?.let { + text("remove_chunk.chunk_target", mapOf("x" to it.x.toString(), "z" to it.z.toString())) + } ?: text("remove_chunk.map_choose"), + text("remove_chunk.chunk_count", mapOf("count" to geometry.chunks.size.toString())), + text("remove_chunk.no_refund") + ) + inventory.setItem( + confirmIndex, + item(if (target == null) Material.GRAY_DYE else Material.REDSTONE_BLOCK, "remove_chunk.confirm").apply { + itemMeta = itemMeta.apply { lore(lore) } + } + ) + inventory.setItem(cancelIndex, item(Material.BARRIER, "remove_chunk.cancel")) + } + + override fun handleClick(event: InventoryClickEvent) { + if (!isThisInventory(event.inventory)) return + event.isCancelled = true + val player = event.whoClicked as? Player ?: return + if (submitted) return + + panButtons[event.rawSlot]?.let { + viewCenter = viewCenter?.neighbour(it) ?: viewCenter + offered?.let { plot -> renderInventory(player, plot) } + return + } + + if (event.rawSlot in mapSlots) { + val plot = offered ?: return + val geometry = plot.geometry as? ChunkGeometry ?: return + val row = event.rawSlot / 9 + val column = event.rawSlot % 9 + val candidate = viewCenter?.let { offset(it, column - 4, row - 3) } ?: return + if (candidate !in geometry.chunks) return + when { + geometry.chunks.size <= 1 -> player.sendMessage(text("remove_chunk.last_locked")) + candidate == anchor(plot) -> player.sendMessage(text("remove_chunk.anchor_locked")) + geometry.without(candidate) == null -> player.sendMessage(text("remove_chunk.disconnect_locked")) + else -> { + target = candidate + renderInventory(player, plot) + } + } + return + } + + if (event.rawSlot == bordersIndex) { + plugin.cacheManager.getPlot(plotId)?.let { + Helpers(plugin).visualizePlotBorder3D(player, it, Helpers(plugin).borderDurationSeconds(), 2, 4) + } + return + } + + if (event.rawSlot == cancelIndex) { + submitted = true + player.scheduler.run(plugin, { + if (player.openInventory.topInventory !== inventory) return@run + player.closeInventory() + plugin.guiHandler.unregisterGui(player) + }, null) + return + } + + if (event.rawSlot != confirmIndex) return + val selected = target ?: run { + player.sendMessage(text("remove_chunk.map_choose")) + return + } + val current = refreshSelectedOffer(player, selected) ?: return + submitted = true + inventory.setItem(confirmIndex, item(Material.CLOCK, "remove_chunk.processing")) + + val request = ChunkRemovalTransaction.Request( + plotId = plotId, + owner = player.uniqueId, + actor = player.uniqueId, + target = selected, + expectedRevision = current.geometryRevision + ) + plugin.server.scheduler.runTaskAsynchronously(plugin, Runnable { + val result = try { + plugin.databaseHandler.removeChunkAtomically(request) + } catch (failure: Exception) { + plugin.logger.err("Chunk removal from plot $plotId failed: ${failure.message}") + null + } + if (!plugin.isEnabled) return@Runnable + player.scheduler.run(plugin, { + if (!player.isOnline) return@run + submitted = false + when (result) { + is ChunkRemovalTransaction.Result.Success -> { + target = null + player.sendMessage(plugin.messageHandler.stringMessageToComponent( + "plots", "chunk_remove_success", + mapOf("x" to result.chunk.x.toString(), "z" to result.chunk.z.toString()) + )) + plugin.cacheManager.getPlot(plotId)?.let { fresh -> + if (player.world.name == fresh.world) { + Helpers(plugin).visualizePlotBorder3D( + player, fresh, Helpers(plugin).borderDurationSeconds(), 2, 4 + ) + } + if (player.openInventory.topInventory === inventory) { + offered = fresh + renderInventory(player, fresh) + } + } + } + ChunkRemovalTransaction.Result.LastChunk -> error(player, "chunk_remove_last") + ChunkRemovalTransaction.Result.AnchorChunk -> error(player, "chunk_remove_anchor") + ChunkRemovalTransaction.Result.WouldDisconnect -> error(player, "chunk_remove_disconnect") + ChunkRemovalTransaction.Result.NotOwner -> error(player, "not_owner") + ChunkRemovalTransaction.Result.WrongGeometry -> error(player, "chunk_remove_wrong_geometry") + ChunkRemovalTransaction.Result.PlotNotFound -> error(player, "plot_not_found") + ChunkRemovalTransaction.Result.StaleQuote, + ChunkRemovalTransaction.Result.ChunkNotFound -> error(player, "chunk_remove_changed") + null -> error(player, "chunk_remove_failed") + } + if (result !is ChunkRemovalTransaction.Result.Success && player.openInventory.topInventory === inventory) { + val fresh = plugin.cacheManager.getPlot(plotId) + if (fresh != null && fresh.geometry is ChunkGeometry) { + offered = fresh + target = null + renderInventory(player, fresh) + } + } + }, null) + }) + } + + private fun refreshSelectedOffer(player: Player, selected: ChunkPosition): PlotData? { + val current = plugin.cacheManager.getPlot(plotId) ?: run { + error(player, "plot_not_found") + return null + } + val geometry = current.geometry as? ChunkGeometry ?: run { + error(player, "chunk_remove_wrong_geometry") + return null + } + if (current.ownerUuid != player.uniqueId || !PermissionChecker.canExpandPlot(player)) { + error(player, if (current.ownerUuid != player.uniqueId) "not_owner" else "no_permission") + return null + } + when { + selected !in geometry.chunks -> error(player, "chunk_remove_changed") + geometry.chunks.size <= 1 -> error(player, "chunk_remove_last") + selected == anchor(current) -> error(player, "chunk_remove_anchor") + geometry.without(selected) == null -> error(player, "chunk_remove_disconnect") + else -> { + offered = current + target = selected + return current + } + } + target = null + renderInventory(player, current) + return null + } + + private fun isRemovable(plot: PlotData, geometry: ChunkGeometry, position: ChunkPosition): Boolean = + position != anchor(plot) && geometry.without(position) != null + + private fun anchor(plot: PlotData): ChunkPosition = ChunkPosition.atBlock(plot.x, plot.z) + + private fun text(key: String, replacements: Map = emptyMap()) = + plugin.messageHandler.stringMessageToComponentNoPrefix("GUI", key, replacements) + + private fun error(player: Player, key: String) { + player.sendMessage(plugin.messageHandler.stringMessageToComponent("error", key)) + } + + private fun item(material: Material, key: String) = ItemStack(material).apply { + itemMeta = itemMeta.apply { displayName(text(key)) } + } + + private fun mapItem(material: Material, key: String, position: ChunkPosition) = item(material, key).apply { + itemMeta = itemMeta.apply { + lore(listOf(text("remove_chunk.map_coordinates", mapOf( + "x" to position.x.toString(), "z" to position.z.toString() + )))) + } + } + + private fun offset(origin: ChunkPosition, dx: Int, dz: Int): ChunkPosition? { + val x = origin.x.toLong() + dx + val z = origin.z.toLong() + dz + if (x !in ChunkPosition.MIN_COORDINATE.toLong()..ChunkPosition.MAX_COORDINATE.toLong() || + z !in ChunkPosition.MIN_COORDINATE.toLong()..ChunkPosition.MAX_COORDINATE.toLong()) return null + return ChunkPosition(x.toInt(), z.toInt()) + } +} diff --git a/src/main/kotlin/pl/syntaxdevteam/plotsx/gui/PlotGUI.kt b/src/main/kotlin/pl/syntaxdevteam/plotsx/gui/PlotGUI.kt index f5a7e0a..d90a769 100644 --- a/src/main/kotlin/pl/syntaxdevteam/plotsx/gui/PlotGUI.kt +++ b/src/main/kotlin/pl/syntaxdevteam/plotsx/gui/PlotGUI.kt @@ -32,6 +32,7 @@ class PlotGUI( private val renameIndex = 29 private val listIndex = 31 private val expandIndex = 33 + private val removeChunkIndex = 35 private val membersIndex = 13 override fun open(player: Player) { @@ -90,6 +91,17 @@ class PlotGUI( message.stringMessageToComponentNoPrefix("GUI", "plot.material_name.expand") ) ) + if (current.radius == null) { + inventory.setItem( + removeChunkIndex, + createItem( + Material.REDSTONE_BLOCK, + message.stringMessageToComponentNoPrefix("GUI", "plot.material_name.remove_chunk") + ) + ) + } else { + inventory.clear(removeChunkIndex) + } super.open(player) } @@ -161,6 +173,18 @@ class PlotGUI( } } + removeChunkIndex -> { + if (pd.radius != null) { + player.sendMessage(message.stringMessageToComponent("error", "chunk_remove_wrong_geometry")) + } else if (!PermissionChecker.canExpandPlot(player)) { + player.sendMessage(message.stringMessageToComponent("error", "no_permission")) + } else if (pd.ownerUuid != player.uniqueId) { + player.sendMessage(message.stringMessageToComponent("error", "not_owner")) + } else { + plugin.guiHandler.registerGui(player, ChunkRemoveGUI(plugin, pd.id)) + } + } + plotIndex -> { plugin.server.scheduler.runTask(plugin, Runnable { helpers.visualizePlotBorder3D( diff --git a/src/main/resources/lang/messages_en.yml b/src/main/resources/lang/messages_en.yml index 8a96825..9c65dea 100644 --- a/src/main/resources/lang/messages_en.yml +++ b/src/main/resources/lang/messages_en.yml @@ -42,6 +42,12 @@ error: expand_area_limit: "Expanding would exceed your combined land allowance." expand_collision: "The new plot boundary would overlap another plot." expand_failed: "The plot could not be expanded." + chunk_remove_failed: "The chunk could not be removed from the plot." + chunk_remove_changed: "The plot geometry changed during the operation. Select the chunk again." + chunk_remove_last: "The last plot chunk cannot be removed." + chunk_remove_anchor: "The plot anchor chunk cannot be removed." + chunk_remove_disconnect: "Removing this chunk would split the plot into disconnected parts." + chunk_remove_wrong_geometry: "Individual chunk removal is available only for chunk plots." console: "Only players can use this command." @@ -54,6 +60,8 @@ plots: claim_success: "Plot has been successfully created!" unclaim_success: "Plot has been successfully removed." unclaim_cancelled: "Plot removal has been cancelled." + chunk_expand_success: "Purchased chunk , . You can immediately choose another one." + chunk_remove_success: "Removed chunk , from the plot." enter_plot: "You entered plot " leave_plot: "You left plot " approaching_plot: "You are approaching plot . Protections will change soon." @@ -168,6 +176,7 @@ GUI: list: "Plot List" rename: "Rename Plot" expand: "Expand Plot" + remove_chunk: "Remove individual chunk" info: owner: "Owner: " plot_name: "Name: " @@ -210,9 +219,34 @@ GUI: price: "Price per expansion: " title: "Expand Plot" confirm: "Confirm expansion" + processing: "Processing purchase..." cancel: "Cancel" area: "Total land after expansion: /" max_radius: "Maximum boundary distance from center: " + remove_chunk: + title: "Remove Plot Chunks" + chunk_target: "Selected chunk: , " + chunk_count: "Chunks before removal: " + map_choose: "Choose a plot chunk to remove." + map_player: "Your chunk — click to select" + map_anchor: "Anchor chunk — cannot be removed" + map_removable: "Plot chunk — click to remove" + map_selected: "Selected chunk for removal" + map_locked: "This chunk cannot be safely removed" + map_empty: "Outside the plot" + map_coordinates: "Chunk X , Z " + map_pan_north: "Pan map north" + map_pan_east: "Pan map east" + map_pan_south: "Pan map south" + map_pan_west: "Pan map west" + show_borders: "Show borders" + confirm: "Remove selected chunk" + processing: "Removing chunk..." + cancel: "Close" + no_refund: "No refund; the expansion price tier will not be decreased." + anchor_locked: "The plot anchor chunk must remain claimed." + last_locked: "The last plot chunk cannot be removed." + disconnect_locked: "Removing this chunk would split the plot into disconnected parts." flags: grave-create: diff --git a/src/main/resources/lang/messages_pl.yml b/src/main/resources/lang/messages_pl.yml index f12e9aa..013f36d 100644 --- a/src/main/resources/lang/messages_pl.yml +++ b/src/main/resources/lang/messages_pl.yml @@ -42,6 +42,12 @@ error: expand_area_limit: "Rozszerzenie przekroczyłoby Twój łączny limit terenu." expand_collision: "Nowa granica działki kolidowałaby z inną działką." expand_failed: "Nie udało się rozszerzyć działki." + chunk_remove_failed: "Nie udało się usunąć chunka z działki." + chunk_remove_changed: "Układ działki zmienił się w trakcie operacji. Wybierz chunk ponownie." + chunk_remove_last: "Nie można usunąć ostatniego chunka działki." + chunk_remove_anchor: "Nie można usunąć bazowego chunka działki." + chunk_remove_disconnect: "Usunięcie tego chunka rozdzieliłoby działkę na osobne części." + chunk_remove_wrong_geometry: "Usuwanie pojedynczych chunków jest dostępne tylko dla działek chunkowych." console: "Tylko gracz może użyć tej komendy." reload: "Wystąpił błąd podczas przeładowania konfiguracji: " @@ -53,6 +59,8 @@ plots: claim_success: "Działka została pomyślnie utworzona!" unclaim_success: "Działka została pomyślnie usunięta." unclaim_cancelled: "Anulowano usuwanie działki." + chunk_expand_success: "Dokupiono chunk , . Możesz od razu wybrać następny." + chunk_remove_success: "Usunięto chunk , z działki." enter_plot: "Wszedłeś na działkę " leave_plot: "Opuszczono działkę " approaching_plot: "Zbliżasz się do działki . Ochrona za chwilę się zmieni." @@ -168,6 +176,7 @@ GUI: list: "Lista działek" rename: "Zmień nazwę działki" expand: "Rozszerz działkę" + remove_chunk: "Usuń pojedynczy chunk" info: owner: "Właściciel: " plot_name: "Nazwa: " @@ -210,9 +219,34 @@ GUI: price: "Cena za segment: " title: "Rozszerzanie działki" confirm: "Potwierdź rozszerzenie" + processing: "Przetwarzanie zakupu..." cancel: "Anuluj" area: "Łączny teren po zmianie: /" max_radius: "Maksymalna odległość granicy od środka: " + remove_chunk: + title: "Usuwanie chunków działki" + chunk_target: "Wybrany chunk: , " + chunk_count: "Chunki przed usunięciem: " + map_choose: "Wybierz chunk działki, który chcesz usunąć." + map_player: "Twój chunk — kliknij, aby wybrać" + map_anchor: "Chunk bazowy — nie można usunąć" + map_removable: "Chunk działki — kliknij, aby usunąć" + map_selected: "Wybrany chunk do usunięcia" + map_locked: "Tego chunka nie można bezpiecznie usunąć" + map_empty: "Poza działką" + map_coordinates: "Chunk X , Z " + map_pan_north: "Przesuń mapę na północ" + map_pan_east: "Przesuń mapę na wschód" + map_pan_south: "Przesuń mapę na południe" + map_pan_west: "Przesuń mapę na zachód" + show_borders: "Pokaż granice" + confirm: "Usuń wybrany chunk" + processing: "Usuwanie chunka..." + cancel: "Zamknij" + no_refund: "Bez zwrotu kosztów; poziom cenowy rozszerzeń nie zostanie cofnięty." + anchor_locked: "Chunk bazowy działki musi pozostać zajęty." + last_locked: "Nie można usunąć ostatniego chunka działki." + disconnect_locked: "Usunięcie tego chunka rozdzieliłoby działkę na osobne części." flags: diff --git a/src/test/kotlin/pl/syntaxdevteam/plotsx/databases/ChunkRemovalTransactionTest.kt b/src/test/kotlin/pl/syntaxdevteam/plotsx/databases/ChunkRemovalTransactionTest.kt new file mode 100644 index 0000000..0c2ca1d --- /dev/null +++ b/src/test/kotlin/pl/syntaxdevteam/plotsx/databases/ChunkRemovalTransactionTest.kt @@ -0,0 +1,115 @@ +package pl.syntaxdevteam.plotsx.databases + +import org.junit.Assert.* +import org.junit.Test +import pl.syntaxdevteam.plotsx.geometry.ChunkGeometry +import pl.syntaxdevteam.plotsx.geometry.ChunkPosition +import pl.syntaxdevteam.plotsx.geometry.ClassicGeometry +import java.sql.Connection +import java.sql.DriverManager +import java.util.UUID + +class ChunkRemovalTransactionTest { + private val owner = UUID.randomUUID() + private val actor = UUID.randomUUID() + private val anchor = ChunkPosition(0, 0) + private val east = ChunkPosition(1, 0) + private val east2 = ChunkPosition(2, 0) + + private fun databases( + geometry: ChunkGeometry = ChunkGeometry(setOf(anchor, east)), + action: (Connection, Int) -> Unit + ) { + for ((type, url) in listOf("sqlite" to "jdbc:sqlite::memory:", "h2" to "jdbc:h2:mem:${UUID.randomUUID()}")) { + DriverManager.getConnection(url).use { conn -> + DatabaseSchema.statements(type).forEach { sql -> conn.createStatement().use { it.execute(sql) } } + DatabaseMigrations.migrate(conn, type) + val id = insert(conn, geometry) + action(conn, id) + } + } + } + + private fun insert(conn: Connection, geometry: pl.syntaxdevteam.plotsx.geometry.PlotGeometry): Int { + conn.autoCommit = false + val classic = geometry as? ClassicGeometry + val x = classic?.base?.x ?: 0 + val z = classic?.base?.z ?: 0 + val id = PlotRepository.insert(conn, owner, "world", "plot", x, 64, z, 0, geometry) + conn.commit() + conn.autoCommit = true + return id + } + + private fun request(id: Int, target: ChunkPosition = east, revision: Long = 0) = + ChunkRemovalTransaction.Request(id, owner, actor, target, revision) + + @Test fun `removes one leaf chunk increments revision logs actor and preserves price level`() = + databases { conn, id -> + conn.prepareStatement("INSERT INTO plot_expansion_levels (expansion_level, plot_id) VALUES (?, ?)").use { + it.setInt(1, 7); it.setInt(2, id); it.executeUpdate() + } + val result = ChunkRemovalTransaction.remove(conn, request(id)) + assertEquals(ChunkRemovalTransaction.Result.Success(east, 1), result) + val plot = PlotRepository.readAll(conn).single() + assertEquals(setOf(anchor), (plot.geometry as ChunkGeometry).chunks) + assertEquals(1L, plot.geometryRevision) + conn.prepareStatement("SELECT expansion_level FROM plot_expansion_levels WHERE plot_id = ?").use { + it.setInt(1, id) + it.executeQuery().use { rs -> assertTrue(rs.next()); assertEquals(7, rs.getInt(1)) } + } + conn.createStatement().use { it.executeQuery("SELECT actor_uuid, action FROM plot_logs").use { rs -> + assertTrue(rs.next()) + assertEquals(actor.toString(), rs.getString(1)) + assertEquals("REMOVE_CHUNK:1,0:1", rs.getString(2)) + assertFalse(rs.next()) + } } + assertTrue(conn.autoCommit) + } + + @Test fun `rejects last anchor and disconnecting chunks`() { + databases(ChunkGeometry(setOf(anchor))) { conn, id -> + assertEquals(ChunkRemovalTransaction.Result.LastChunk, ChunkRemovalTransaction.remove(conn, request(id, anchor))) + } + databases(ChunkGeometry(setOf(anchor, east))) { conn, id -> + assertEquals(ChunkRemovalTransaction.Result.AnchorChunk, ChunkRemovalTransaction.remove(conn, request(id, anchor))) + } + databases(ChunkGeometry(setOf(anchor, east, east2))) { conn, id -> + assertEquals(ChunkRemovalTransaction.Result.WouldDisconnect, ChunkRemovalTransaction.remove(conn, request(id, east))) + assertEquals(setOf(anchor, east, east2), (PlotRepository.readAll(conn).single().geometry as ChunkGeometry).chunks) + } + } + + @Test fun `rejects stale foreign missing and classic targets`() = databases { conn, id -> + assertEquals(ChunkRemovalTransaction.Result.StaleQuote, + ChunkRemovalTransaction.remove(conn, request(id, revision = 1))) + assertEquals(ChunkRemovalTransaction.Result.NotOwner, + ChunkRemovalTransaction.remove(conn, request(id).copy(owner = UUID.randomUUID()))) + assertEquals(ChunkRemovalTransaction.Result.ChunkNotFound, + ChunkRemovalTransaction.remove(conn, request(id, ChunkPosition(9, 9)))) + assertEquals(ChunkRemovalTransaction.Result.PlotNotFound, + ChunkRemovalTransaction.remove(conn, request(Int.MAX_VALUE))) + val classic = insert(conn, ClassicGeometry(PlotSegment(100, 100, 1))) + assertEquals(ChunkRemovalTransaction.Result.WrongGeometry, + ChunkRemovalTransaction.remove(conn, request(classic))) + } + + @Test fun `late log failure rolls back deleted chunk and revision`() = databases { conn, id -> + conn.createStatement().use { it.execute("DROP TABLE plot_logs") } + assertThrows(java.sql.SQLException::class.java) { ChunkRemovalTransaction.remove(conn, request(id)) } + val plot = PlotRepository.readAll(conn).single() + assertEquals(0L, plot.geometryRevision) + assertEquals(setOf(anchor, east), (plot.geometry as ChunkGeometry).chunks) + assertTrue(conn.autoCommit) + } + + @Test fun `caller transaction can roll back successful removal`() = databases { conn, id -> + conn.autoCommit = false + assertTrue(ChunkRemovalTransaction.applyInTransaction(conn, request(id)) is ChunkRemovalTransaction.Result.Success) + conn.rollback() + conn.autoCommit = true + val plot = PlotRepository.readAll(conn).single() + assertEquals(0L, plot.geometryRevision) + assertEquals(setOf(anchor, east), (plot.geometry as ChunkGeometry).chunks) + } +}