diff --git a/docs/dialogs.md b/docs/dialogs.md index b65e746..537e2ff 100644 --- a/docs/dialogs.md +++ b/docs/dialogs.md @@ -10,13 +10,13 @@ interactions: translated-clients: false ``` -`mode: legacy` wymusza dotychczasowy interfejs. Lokalne ViaVersion, ViaBackwards, ProtocolSupport, Geyser-Spigot i floodgate powodują użycie tego interfejsu dla wszystkich graczy, również nowych klientów. Jeżeli translacja działa wyłącznie na proxy, administrator powinien ustawić `translated-clients: true`. Nie próbujemy zgadywać wersji klienta na podstawie wersji serwera. +`mode: legacy` wymusza dotychczasowy interfejs czatowy. W trybie `auto` sama obecność ViaVersion, ViaBackwards, ProtocolSupport, Geyser lub Floodgate nie wyłącza już Dialog API dla całego serwera — na nowoczesnym Paper, który udostępnia API, zmiana nazwy korzysta z natywnego formularza. Jeśli konkretne wdrożenie tłumaczy protokół dla starszych klientów i administrator świadomie potrzebuje jednego kompatybilnego interfejsu dla wszystkich, może ustawić `translated-clients: true`; wtedy wejście tekstowe wraca do czatu. Nowe komunikaty znajdują się w sekcji `dialogs` plików językowych PL i EN. Nazwa ma maksymalnie 255 znaków, zgodnie ze schematem bazy. Czat i dialog stosują tę samą walidację, filtr CleanerX i kontrolę uprawnień. Zapis obecnej nazwy jest dozwolony. Formularz jest związany z graczem, wygasa po 60 sekundach i może zostać zatwierdzony tylko raz. Kolejny formularz, otwarcie ekwipunku lub wyjście gracza unieważniają poprzednią sesję. Anulowanie nie wykonuje operacji. -Adapter dialogowy jest ładowany refleksyjnie; wspólna fasada nie zawiera typów Dialog API. Błąd wyświetlenia przywraca obsługiwane menu lub czat. Przełączanie na dialog i obsługa odpowiedzi używają schedulera gracza. Nie jest to audyt zgodności całego pluginu z Folia — istniejące operacje bazy/cache i wizualizacje zawierają również starsze wywołania schedulerów. +Adapter dialogowy jest ładowany refleksyjnie; wspólna fasada nie zawiera typów Dialog API. Błąd wyświetlenia przywraca obsługiwany czat. Przełączanie na dialog i obsługa odpowiedzi używają schedulera gracza. Nie jest to audyt zgodności całego pluginu z Folia — istniejące operacje bazy/cache i wizualizacje zawierają również starsze wywołania schedulerów. ## Weryfikacja @@ -24,9 +24,9 @@ Automatyczne testy `DialogSessionsTest` obejmują stare formularze, obcego gracz Przed wydaniem należy wykonać testy w grze (nie są zastąpione przez testy jednostkowe): -- Paper 1.20.6 i 1.21.6: start JAR bez błędów brakujących klas, wszystkie operacje w starym interfejsie. +- Paper 1.20.6 i 1.21.6: start JAR bez błędów brakujących klas, zmiana nazwy przez czat. - Paper 1.21.7, 1.21.11 i wspierane 26.x: dialog zmiany nazwy, zapis, Anuluj/Escape i timeout; pozostałe operacje w menu ekwipunku. -- `mode: legacy` oraz konfiguracja translatora: działający fallback, w tym obsługa kliknięć w menu. +- Serwer z ViaVersion/ViaBackwards/Geyser: `mode: auto` nadal pokazuje dialog nowoczesnym serwerom; `translated-clients: true` jawnie wymusza kompatybilny fallback czatowy. - Odebranie uprawnień, usunięcie działki lub zmiana właściciela przy otwartym formularzu: odrzucenie nieaktualnej operacji. - Rozszerzenie w menu ekwipunku: zmiana ceny/poziomu, podwójne zatwierdzenie, brak środków i poprawne rozliczenie. - Zmiana nazwy: pusta/niedozwolona nazwa, duplikat, bieżąca nazwa i awaria filtra; błędy zachowują wpis w formularzu. diff --git a/src/main/kotlin/pl/syntaxdevteam/plotsx/databases/PlotCacheLoader.kt b/src/main/kotlin/pl/syntaxdevteam/plotsx/databases/PlotCacheLoader.kt index 67c3d5d..6fea090 100644 --- a/src/main/kotlin/pl/syntaxdevteam/plotsx/databases/PlotCacheLoader.kt +++ b/src/main/kotlin/pl/syntaxdevteam/plotsx/databases/PlotCacheLoader.kt @@ -58,9 +58,13 @@ internal object PlotCacheLoader { require(level >= 0) { "Invalid expansion level" } levels[rows.getInt("plot_id")] = level } } + val teleportSpawns = PlotSpawnRepository.readAll(conn, plotId) conn.commit() return PlotCacheData(plots.map { plot -> - if (plot.radius == null) plot.copy(expansionLevel = levels[plot.id] ?: (plot.chunks.size - 1)) else plot + val priced = if (plot.radius == null) { + plot.copy(expansionLevel = levels[plot.id] ?: (plot.chunks.size - 1)) + } else plot + priced.copy(teleportSpawn = teleportSpawns[plot.id]) }, flags, members) } catch (failure: Exception) { conn.rollback() diff --git a/src/main/kotlin/pl/syntaxdevteam/plotsx/databases/PlotData.kt b/src/main/kotlin/pl/syntaxdevteam/plotsx/databases/PlotData.kt index 5bfe992..2b8fb0a 100644 --- a/src/main/kotlin/pl/syntaxdevteam/plotsx/databases/PlotData.kt +++ b/src/main/kotlin/pl/syntaxdevteam/plotsx/databases/PlotData.kt @@ -19,7 +19,8 @@ data class PlotData @JvmOverloads constructor( val extensions: List = emptyList(), val chunks: Set = emptySet(), val geometryRevision: Long = 0, - val expansionLevel: Int = if (radius == null) chunks.size - 1 else extensions.size + val expansionLevel: Int = if (radius == null) chunks.size - 1 else extensions.size, + val teleportSpawn: PlotTeleportSpawn? = null ) { val geometry: PlotGeometry = if (radius != null) { require(chunks.isEmpty()) { "Classic plots cannot have chunks" } diff --git a/src/main/kotlin/pl/syntaxdevteam/plotsx/databases/PlotSpawnRepository.kt b/src/main/kotlin/pl/syntaxdevteam/plotsx/databases/PlotSpawnRepository.kt new file mode 100644 index 0000000..fe467f4 --- /dev/null +++ b/src/main/kotlin/pl/syntaxdevteam/plotsx/databases/PlotSpawnRepository.kt @@ -0,0 +1,172 @@ +package pl.syntaxdevteam.plotsx.databases + +import pl.syntaxdevteam.plotsx.PlotsX +import java.sql.Connection +import java.sql.DriverManager +import java.util.UUID + +/** Player-selected teleport point stored independently from plot geometry. */ +data class PlotTeleportSpawn(val x: Int, val y: Int, val z: Int) + +internal object PlotSpawnRepository { + sealed interface SaveResult { + data object Success : SaveResult + data object PlotNotFound : SaveResult + data object NotOwner : SaveResult + data object DatabaseError : SaveResult + } + + fun schema(): String = """ + CREATE TABLE IF NOT EXISTS plot_spawns ( + plot_id INTEGER PRIMARY KEY, + x INTEGER NOT NULL, + y INTEGER NOT NULL, + z INTEGER NOT NULL, + FOREIGN KEY (plot_id) REFERENCES plots(plot_id) ON DELETE CASCADE + ) + """.trimIndent() + + fun migrate(conn: Connection) { + conn.createStatement().use { it.execute(schema()) } + } + + fun exists(conn: Connection): Boolean = + conn.metaData.getTables(conn.catalog, conn.schema, "%", null).use { rows -> + var found = false + while (rows.next()) { + if (rows.getString("TABLE_NAME").equals("plot_spawns", ignoreCase = true)) { + found = true + break + } + } + found + } + + fun readAll(conn: Connection, plotId: Int? = null): Map { + if (!exists(conn)) return emptyMap() + val sql = "SELECT plot_id, x, y, z FROM plot_spawns" + + if (plotId == null) " ORDER BY plot_id" else " WHERE plot_id = ?" + return conn.prepareStatement(sql).use { stmt -> + if (plotId != null) stmt.setInt(1, plotId) + stmt.executeQuery().use { rows -> + buildMap { + while (rows.next()) { + put( + rows.getInt("plot_id"), + PlotTeleportSpawn(rows.getInt("x"), rows.getInt("y"), rows.getInt("z")) + ) + } + } + } + } + } + + /** + * Rare metadata write. The caller must invoke this off the server thread. + * A short dedicated JDBC connection avoids exposing DatabaseHandler internals while ordinary + * protection and teleport reads remain entirely cache-backed. + */ + fun save( + plugin: PlotsX, + plotId: Int, + expectedOwner: UUID, + actor: UUID, + spawn: PlotTeleportSpawn + ): SaveResult = try { + openConnection(plugin).use { conn -> + migrate(conn) + save(conn, plotId, expectedOwner, actor, spawn) + } + } catch (failure: Exception) { + plugin.logger.err("Teleport spawn update failed for plot $plotId: ${failure.message}") + SaveResult.DatabaseError + } + + private fun openConnection(plugin: PlotsX): Connection { + val type = plugin.config.getString("database.type")?.lowercase() ?: "sqlite" + val dbName = plugin.config.getString("database.sql.dbname") ?: plugin.name + val user = plugin.config.getString("database.sql.username") ?: "ROOT" + val password = plugin.config.getString("database.sql.password") ?: "U5eV3ryStr0ngP4ssw0rd" + val connection = when (type) { + "mysql", "mariadb" -> DriverManager.getConnection( + "jdbc:mariadb://${plugin.config.getString("database.sql.host")}:${plugin.config.getString("database.sql.port")}/$dbName", + user, + password + ) + "postgresql" -> DriverManager.getConnection( + "jdbc:postgresql://${plugin.config.getString("database.sql.host")}:${plugin.config.getString("database.sql.port")}/$dbName", + user, + password + ) + "sqlite" -> DriverManager.getConnection("jdbc:sqlite:${plugin.dataFolder}/$dbName.db") + "h2" -> DriverManager.getConnection("jdbc:h2:./${plugin.dataFolder}/$dbName", user, password) + else -> throw IllegalArgumentException("Unsupported database type: $type") + } + if (type == "sqlite") { + connection.createStatement().use { + it.execute("PRAGMA journal_mode=WAL;") + it.execute("PRAGMA foreign_keys=ON;") + } + } + return connection + } + + private fun save( + conn: Connection, + plotId: Int, + expectedOwner: UUID, + actor: UUID, + spawn: PlotTeleportSpawn + ): SaveResult { + require(conn.autoCommit) { "Teleport-spawn update requires a dedicated auto-commit connection" } + conn.autoCommit = false + try { + conn.prepareStatement("DELETE FROM plot_spawns WHERE plot_id = ?").use { + it.setInt(1, plotId) + it.executeUpdate() + } + val inserted = conn.prepareStatement( + """ + INSERT INTO plot_spawns (plot_id, x, y, z) + SELECT plot_id, ?, ?, ? FROM plots + WHERE plot_id = ? AND owner_uuid = ? + """.trimIndent() + ).use { stmt -> + stmt.setInt(1, spawn.x) + stmt.setInt(2, spawn.y) + stmt.setInt(3, spawn.z) + stmt.setInt(4, plotId) + stmt.setString(5, expectedOwner.toString()) + stmt.executeUpdate() + } + if (inserted != 1) { + val plotExists = conn.prepareStatement("SELECT 1 FROM plots WHERE plot_id = ?").use { stmt -> + stmt.setInt(1, plotId) + stmt.executeQuery().use { it.next() } + } + conn.rollback() + return if (plotExists) SaveResult.NotOwner else SaveResult.PlotNotFound + } + conn.prepareStatement( + "INSERT INTO plot_logs (plot_id, action, actor_uuid, timestamp) VALUES (?, ?, ?, ?)" + ).use { stmt -> + stmt.setInt(1, plotId) + stmt.setString(2, "SET_TELEPORT_SPAWN:${spawn.x},${spawn.y},${spawn.z}") + stmt.setString(3, actor.toString()) + stmt.setLong(4, System.currentTimeMillis()) + stmt.executeUpdate() + } + conn.commit() + return SaveResult.Success + } catch (failure: Exception) { + try { + conn.rollback() + } catch (rollback: Exception) { + failure.addSuppressed(rollback) + } + throw failure + } finally { + conn.autoCommit = true + } + } +} diff --git a/src/main/kotlin/pl/syntaxdevteam/plotsx/databases/SqlBackup.kt b/src/main/kotlin/pl/syntaxdevteam/plotsx/databases/SqlBackup.kt index 8518f01..d4a93df 100644 --- a/src/main/kotlin/pl/syntaxdevteam/plotsx/databases/SqlBackup.kt +++ b/src/main/kotlin/pl/syntaxdevteam/plotsx/databases/SqlBackup.kt @@ -45,13 +45,16 @@ internal object SqlBackup { val chunkSchema = DatabaseMigrations.isChunkSchema(conn) val journalSchema = OperationJournal.exists(conn) val identityAliases = tableExists(conn, "plotsx_uuid_aliases") + val spawnSchema = PlotSpawnRepository.exists(conn) require(!journalSchema || chunkSchema) { "Journal requires the geometry schema" } val exportedTables = (if (chunkSchema) tables + "plot_chunks" else tables) + (if (journalSchema) listOf("plot_operations") else emptyList()) + - (if (identityAliases) listOf("plotsx_uuid_aliases") else emptyList()) + (if (identityAliases) listOf("plotsx_uuid_aliases") else emptyList()) + + (if (spawnSchema) listOf("plot_spawns") else emptyList()) val schema = (if (chunkSchema) DatabaseSchema.chunkStatements(dialect) else DatabaseSchema.statements(dialect)) + (if (journalSchema) listOf(OperationJournal.schema()) else emptyList()) + - (if (identityAliases) listOf(IdentityMigrationStore.aliasSchema()) else emptyList()) + (if (identityAliases) listOf(IdentityMigrationStore.aliasSchema()) else emptyList()) + + (if (spawnSchema) listOf(PlotSpawnRepository.schema()) else emptyList()) Files.createDirectories(directory.toPath()) val destination = File(directory, "backup.sql") val temporary = Files.createTempFile(directory.toPath(), "backup-", ".tmp") @@ -63,7 +66,14 @@ internal object SqlBackup { if (journalSchema) OperationJournal.readAll(conn) Files.newBufferedWriter(temporary, Charsets.UTF_8).use { writer -> fun line(sql: String) { writer.write(sql); writer.newLine() } - line("-- PlotsX SQL backup v${if (identityAliases) 4 else if (journalSchema) 3 else if (chunkSchema) 2 else 1} dialect=$dialect") + val version = when { + spawnSchema -> 5 + identityAliases -> 4 + journalSchema -> 3 + chunkSchema -> 2 + else -> 1 + } + line("-- PlotsX SQL backup v$version dialect=$dialect") schema.forEach { line(it.trim().removeSuffix(";").replace(Regex("\\s+"), " ") + ";") } line("BEGIN;") exportedTables.asReversed().forEach { line("DELETE FROM $it;") } @@ -114,7 +124,8 @@ internal object SqlBackup { fun restore(conn: Connection, dialect: String, file: File, allowChunkPlots: Boolean = false) { // Validate the entire file before modifying the database. Only our versioned format is supported. val lines = file.readLines(Charsets.UTF_8) - val identityBackup = lines.firstOrNull() == "-- PlotsX SQL backup v4 dialect=$dialect" + val spawnBackup = lines.firstOrNull() == "-- PlotsX SQL backup v5 dialect=$dialect" + val identityBackup = spawnBackup || lines.firstOrNull() == "-- PlotsX SQL backup v4 dialect=$dialect" val journalBackup = identityBackup || lines.firstOrNull() == "-- PlotsX SQL backup v3 dialect=$dialect" val chunkBackup = journalBackup || lines.firstOrNull() == "-- PlotsX SQL backup v2 dialect=$dialect" require(chunkBackup || lines.firstOrNull() == "-- PlotsX SQL backup v1 dialect=$dialect") { @@ -122,13 +133,15 @@ internal object SqlBackup { } val schema = ((if (chunkBackup) DatabaseSchema.chunkStatements(dialect) else DatabaseSchema.statements(dialect)) + (if (journalBackup) listOf(OperationJournal.schema()) else emptyList()) + - (if (identityBackup) listOf(IdentityMigrationStore.aliasSchema()) else emptyList())) + (if (identityBackup) listOf(IdentityMigrationStore.aliasSchema()) else emptyList()) + + (if (spawnBackup) listOf(PlotSpawnRepository.schema()) else emptyList())) .map { it.trim().removeSuffix(";").replace(Regex("\\s+"), " ") + ";" } val legacySchema = schema.filterNot { it.startsWith("CREATE TABLE IF NOT EXISTS plot_segments ") } val sourceSchema = if (chunkBackup || lines.drop(1).take(schema.size) == schema) schema else legacySchema val sourceTables = (if (chunkBackup) tables + "plot_chunks" else if (sourceSchema == schema) tables else tables.filterNot { it == "plot_segments" }) + (if (journalBackup) listOf("plot_operations") else emptyList()) + - (if (identityBackup) listOf("plotsx_uuid_aliases") else emptyList()) + (if (identityBackup) listOf("plotsx_uuid_aliases") else emptyList()) + + (if (spawnBackup) listOf("plot_spawns") else emptyList()) require(lines.drop(1).take(sourceSchema.size) == sourceSchema && lines.lastOrNull() == "COMMIT;") { "Incomplete backup or unsupported schema." } val body = lines.drop(1 + sourceSchema.size).dropLast(1) require(body.firstOrNull() == "BEGIN;") { "Missing transaction." } @@ -140,6 +153,7 @@ internal object SqlBackup { if (chunkBackup) DatabaseMigrations.migrate(conn, dialect) if (journalBackup) OperationJournal.migrate(conn) if (identityBackup) IdentityMigrationStore.migrateSchema(conn) + if (spawnBackup) PlotSpawnRepository.migrate(conn) conn.createStatement().use { statement -> schema.forEach { statement.execute(it) } } @@ -149,6 +163,7 @@ internal object SqlBackup { conn.createStatement().use { statement -> if (OperationJournal.exists(conn)) statement.execute("DELETE FROM plot_operations") if (tableExists(conn, "plotsx_uuid_aliases")) statement.execute("DELETE FROM plotsx_uuid_aliases") + if (PlotSpawnRepository.exists(conn)) statement.execute("DELETE FROM plot_spawns") if (chunkTarget) statement.execute("DELETE FROM plot_chunks") statement.execute("DELETE FROM plot_segments") // H2 ALTER TABLE commits implicitly: reset identities only after all data has loaded. diff --git a/src/main/kotlin/pl/syntaxdevteam/plotsx/gui/ChunkExpandGUI.kt b/src/main/kotlin/pl/syntaxdevteam/plotsx/gui/ChunkExpandGUI.kt index 50cc2a8..ec3e8a6 100644 --- a/src/main/kotlin/pl/syntaxdevteam/plotsx/gui/ChunkExpandGUI.kt +++ b/src/main/kotlin/pl/syntaxdevteam/plotsx/gui/ChunkExpandGUI.kt @@ -27,6 +27,7 @@ internal class ChunkExpandGUI( private val bordersIndex = 45 private val confirmIndex = 47 private val cancelIndex = 51 + private val backIndex = 53 private var direction = ExpansionDirection.NORTH private var offered: PlotData? = null private var source: ChunkPosition? = null @@ -113,6 +114,7 @@ internal class ChunkExpandGUI( inventory.setItem(confirmIndex, item(if (target == null) Material.GRAY_DYE else Material.EMERALD_BLOCK, "expand.confirm").apply { itemMeta = itemMeta.apply { lore(lore) } }) inventory.setItem(cancelIndex, item(Material.BARRIER, "expand.cancel")) + inventory.setItem(backIndex, item(Material.ARROW, "back")) plugin.logger.debug( "PlotsX expansion timings: GUI render=${millisSince(renderStarted)} ms [main], " + "plot checks=${cells.size}, WorldGuard checks=${preview.checks}, WorldGuard calls=${preview.calls}" @@ -157,6 +159,10 @@ internal class ChunkExpandGUI( plugin.cacheManager.getPlot(plotId)?.let { Helpers(plugin).visualizePlotBorder3D(player, it, Helpers(plugin).borderDurationSeconds(), 2, 4) } return } + if (event.rawSlot == backIndex) { + returnToPlotMenu(player) + return + } if (event.rawSlot == cancelIndex) { submitted = true player.scheduler.run(plugin, { @@ -174,6 +180,20 @@ internal class ChunkExpandGUI( submit(player) } + private fun returnToPlotMenu(player: Player) { + submitted = true + player.scheduler.run(plugin, { + val current = plugin.cacheManager.getPlot(plotId) + if (current == null) { + player.closeInventory() + plugin.guiHandler.unregisterGui(player) + error(player, "plot_not_found") + return@run + } + plugin.guiHandler.registerGui(player, PlotGUI(plugin, current)) + }, null) + } + /** Refreshes the quote immediately before closing the GUI, so map navigation cannot submit stale data. */ private fun refreshSelectedOffer(player: Player): Boolean { val selected = target ?: return false diff --git a/src/main/kotlin/pl/syntaxdevteam/plotsx/gui/ChunkRemoveGUI.kt b/src/main/kotlin/pl/syntaxdevteam/plotsx/gui/ChunkRemoveGUI.kt index 65c7c89..155a672 100644 --- a/src/main/kotlin/pl/syntaxdevteam/plotsx/gui/ChunkRemoveGUI.kt +++ b/src/main/kotlin/pl/syntaxdevteam/plotsx/gui/ChunkRemoveGUI.kt @@ -29,6 +29,7 @@ internal class ChunkRemoveGUI( private val bordersIndex = 45 private val confirmIndex = 47 private val cancelIndex = 51 + private val backIndex = 53 private var offered: PlotData? = null private var target: ChunkPosition? = null @@ -119,6 +120,7 @@ internal class ChunkRemoveGUI( } ) inventory.setItem(cancelIndex, item(Material.BARRIER, "remove_chunk.cancel")) + inventory.setItem(backIndex, item(Material.ARROW, "back")) } override fun handleClick(event: InventoryClickEvent) { @@ -159,6 +161,11 @@ internal class ChunkRemoveGUI( return } + if (event.rawSlot == backIndex) { + returnToPlotMenu(player) + return + } + if (event.rawSlot == cancelIndex) { submitted = true player.scheduler.run(plugin, { @@ -237,6 +244,20 @@ internal class ChunkRemoveGUI( }) } + private fun returnToPlotMenu(player: Player) { + submitted = true + player.scheduler.run(plugin, { + val current = plugin.cacheManager.getPlot(plotId) + if (current == null) { + player.closeInventory() + plugin.guiHandler.unregisterGui(player) + error(player, "plot_not_found") + return@run + } + plugin.guiHandler.registerGui(player, PlotGUI(plugin, current)) + }, null) + } + private fun refreshSelectedOffer(player: Player, selected: ChunkPosition): PlotData? { val current = plugin.cacheManager.getPlot(plotId) ?: run { error(player, "plot_not_found") diff --git a/src/main/kotlin/pl/syntaxdevteam/plotsx/gui/ExpandGUI.kt b/src/main/kotlin/pl/syntaxdevteam/plotsx/gui/ExpandGUI.kt index 675e945..3dc58c7 100644 --- a/src/main/kotlin/pl/syntaxdevteam/plotsx/gui/ExpandGUI.kt +++ b/src/main/kotlin/pl/syntaxdevteam/plotsx/gui/ExpandGUI.kt @@ -23,6 +23,7 @@ class ExpandGUI(private val plugin: PlotsX, private val plotId: Int) : AbstractG private val helpers = Helpers(plugin) private val confirmIndex = 20 private val cancelIndex = 24 + private val backIndex = 26 private var quotedPrice: BigDecimal? = null private var quotedSegment: PlotSegment? = null private var direction = ExpansionDirection.NORTH @@ -61,6 +62,7 @@ class ExpandGUI(private val plugin: PlotsX, private val plotId: Int) : AbstractG listOf(text("expand.borders_duration", mapOf("seconds" to helpers.borderDurationSeconds().toString()))))) inventory.setItem(confirmIndex, item(Material.EMERALD_BLOCK, text("expand.confirm"), summary)) inventory.setItem(cancelIndex, item(Material.BARRIER, text("expand.cancel"), emptyList())) + inventory.setItem(backIndex, item(Material.ARROW, text("back"), emptyList())) openLegacy(player) } @@ -88,6 +90,10 @@ class ExpandGUI(private val plugin: PlotsX, private val plotId: Int) : AbstractG }, null) return } + if (event.rawSlot == backIndex) { + returnToPlotMenu(player) + return + } if (event.rawSlot !in setOf(confirmIndex, cancelIndex)) return submitted = true player.closeInventory() @@ -99,6 +105,20 @@ class ExpandGUI(private val plugin: PlotsX, private val plotId: Int) : AbstractG } } + private fun returnToPlotMenu(player: Player) { + submitted = true + player.scheduler.run(plugin, { + val current = plugin.cacheManager.getPlot(plotId) + if (current == null) { + player.closeInventory() + plugin.guiHandler.unregisterGui(player) + player.sendMessage(error("plot_not_found")) + return@run + } + plugin.guiHandler.registerGui(player, PlotGUI(plugin, current)) + }, null) + } + private fun expand(player: Player) { if (!PermissionChecker.canExpandPlot(player)) { player.sendMessage(error("no_permission")); return diff --git a/src/main/kotlin/pl/syntaxdevteam/plotsx/gui/PlotGUI.kt b/src/main/kotlin/pl/syntaxdevteam/plotsx/gui/PlotGUI.kt index 713b677..98729fa 100644 --- a/src/main/kotlin/pl/syntaxdevteam/plotsx/gui/PlotGUI.kt +++ b/src/main/kotlin/pl/syntaxdevteam/plotsx/gui/PlotGUI.kt @@ -9,6 +9,8 @@ import org.bukkit.inventory.meta.SkullMeta import pl.syntaxdevteam.plotsx.PlotsX import pl.syntaxdevteam.plotsx.databases.Helpers import pl.syntaxdevteam.plotsx.databases.PlotData +import pl.syntaxdevteam.plotsx.databases.PlotSpawnRepository +import pl.syntaxdevteam.plotsx.databases.PlotTeleportSpawn import pl.syntaxdevteam.plotsx.protection.SafeTeleportUtil import pl.syntaxdevteam.plotsx.permissions.PermissionChecker import pl.syntaxdevteam.plotsx.permissions.PlotAccess @@ -26,13 +28,14 @@ class PlotGUI( private val message = plugin.messageHandler private val helpers = Helpers(plugin) private val plotIndex = 4 - private val flagsIndex = 11 - private val tpaIndex = 15 - private val renameIndex = 29 - private val listIndex = 31 - private val expandIndex = 33 - private val removeChunkIndex = 35 - private val membersIndex = 13 + private val flagsIndex = 10 + private val membersIndex = 12 + private val tpaIndex = 14 + private val spawnIndex = 16 + private val renameIndex = 28 + private val listIndex = 30 + private val expandIndex = 32 + private val removeChunkIndex = 34 override fun open(player: Player) { val targetPlot = plot ?: plugin.cacheManager.getPlotAt( @@ -69,6 +72,17 @@ class PlotGUI( message.stringMessageToComponentNoPrefix("GUI", "plot.material_name.teleport") ) ) + if (current.ownerUuid == player.uniqueId) { + inventory.setItem( + spawnIndex, + createItem( + Material.LODESTONE, + message.stringMessageToComponentNoPrefix("GUI", "plot.material_name.set_spawn") + ) + ) + } else { + inventory.clear(spawnIndex) + } inventory.setItem( renameIndex, createItem( @@ -132,16 +146,18 @@ class PlotGUI( } tpaIndex -> { - plugin.server.scheduler.runTask(plugin, Runnable { - val success = SafeTeleportUtil.safeTeleport(player, pd) - if (success) { - player.sendMessage(message.stringMessageToComponent("plots", "teleport")) - } else { - player.sendMessage(message.stringMessageToComponent("plots", "teleport_failed")) - } - }) + player.scheduler.run(plugin, { + val success = SafeTeleportUtil.safeTeleport(player, pd) + if (success) { + player.sendMessage(message.stringMessageToComponent("plots", "teleport")) + } else { + player.sendMessage(message.stringMessageToComponent("plots", "teleport_failed")) + } + }, null) } + spawnIndex -> setTeleportSpawn(player, pd) + renameIndex -> { if (!PlotAccess(plugin).allowed(player, pd, "rename")) { player.sendMessage(message.stringMessageToComponent("error", "no_permission")) @@ -185,7 +201,7 @@ class PlotGUI( } plotIndex -> { - plugin.server.scheduler.runTask(plugin, Runnable { + player.scheduler.run(plugin, { helpers.visualizePlotBorder3D( player = player, plot = pd, @@ -193,11 +209,66 @@ class PlotGUI( stepXZ = 2, stepY = 4 ) - }) + }, null) } } } + private fun setTeleportSpawn(player: Player, plot: PlotData) { + if (plot.ownerUuid != player.uniqueId) { + player.sendMessage(message.stringMessageToComponent("error", "not_owner")) + return + } + val location = player.location + val spawn = PlotTeleportSpawn(location.blockX, location.blockY, location.blockZ) + if (!player.world.name.equals(plot.world, ignoreCase = true) || !plot.contains(spawn.x, spawn.z)) { + player.sendMessage(message.stringMessageToComponent("error", "teleport_spawn_outside")) + return + } + if (!SafeTeleportUtil.isSafeTeleportSpawn(player.world, plot, spawn)) { + player.sendMessage(message.stringMessageToComponent("error", "teleport_spawn_unsafe")) + return + } + + val owner = player.uniqueId + plugin.server.scheduler.runTaskAsynchronously(plugin, Runnable { + val result = PlotSpawnRepository.save(plugin, plot.id, owner, owner, spawn) + if (result == PlotSpawnRepository.SaveResult.Success) { + try { + plugin.cacheManager.reloadPlotSync(plot.id) + } catch (failure: Exception) { + plugin.logger.err("Teleport spawn cache refresh failed for plot ${plot.id}: ${failure.message}") + if (!plugin.isEnabled) return@Runnable + player.scheduler.run(plugin, { + if (player.isOnline) player.sendMessage(message.stringMessageToComponent("plots", "teleport_spawn_failed")) + }, null) + return@Runnable + } + } + if (!plugin.isEnabled) return@Runnable + player.scheduler.run(plugin, { + if (!player.isOnline) return@run + when (result) { + PlotSpawnRepository.SaveResult.Success -> player.sendMessage( + message.stringMessageToComponent( + "plots", "teleport_spawn_set", + mapOf("x" to spawn.x.toString(), "y" to spawn.y.toString(), "z" to spawn.z.toString()) + ) + ) + PlotSpawnRepository.SaveResult.PlotNotFound -> player.sendMessage( + message.stringMessageToComponent("error", "plot_not_found") + ) + PlotSpawnRepository.SaveResult.NotOwner -> player.sendMessage( + message.stringMessageToComponent("error", "not_owner") + ) + PlotSpawnRepository.SaveResult.DatabaseError -> player.sendMessage( + message.stringMessageToComponent("plots", "teleport_spawn_failed") + ) + } + }, null) + }) + } + private fun createItem(material: Material, name: Component): ItemStack { val item = ItemStack(material) val meta = item.itemMeta!! diff --git a/src/main/kotlin/pl/syntaxdevteam/plotsx/interaction/PlotInteractions.kt b/src/main/kotlin/pl/syntaxdevteam/plotsx/interaction/PlotInteractions.kt index 84841e0..bc11cbd 100644 --- a/src/main/kotlin/pl/syntaxdevteam/plotsx/interaction/PlotInteractions.kt +++ b/src/main/kotlin/pl/syntaxdevteam/plotsx/interaction/PlotInteractions.kt @@ -28,9 +28,6 @@ class PlotInteractions(private val plugin: PlotsX) : Listener { private fun available(): Boolean = backend != null && !plugin.config.getString("interactions.mode", "auto").equals("legacy", true) && - // Without reliable per-client negotiation, use the compatible UI on translated servers. - listOf("ViaVersion", "ViaBackwards", "ProtocolSupport", "Geyser-Spigot", "floodgate") - .none { plugin.server.pluginManager.isPluginEnabled(it) } && !plugin.config.getBoolean("interactions.translated-clients", false) fun rename(player: Player, plotId: Int, initial: String, error: Component? = null): Boolean = diff --git a/src/main/kotlin/pl/syntaxdevteam/plotsx/protection/SafeTeleportUtil.kt b/src/main/kotlin/pl/syntaxdevteam/plotsx/protection/SafeTeleportUtil.kt index 2383640..c2dbd2a 100644 --- a/src/main/kotlin/pl/syntaxdevteam/plotsx/protection/SafeTeleportUtil.kt +++ b/src/main/kotlin/pl/syntaxdevteam/plotsx/protection/SafeTeleportUtil.kt @@ -8,6 +8,7 @@ import org.bukkit.block.Block import org.bukkit.entity.Player import pl.syntaxdevteam.plotsx.compat.PlotCompat import pl.syntaxdevteam.plotsx.databases.PlotData +import pl.syntaxdevteam.plotsx.databases.PlotTeleportSpawn import java.util.ArrayDeque object SafeTeleportUtil { @@ -43,6 +44,7 @@ object SafeTeleportUtil { * - dwa bloki nad (y+1, y+2) są powietrzem */ private fun isSafeBlock(world: World, x: Int, y: Int, z: Int): Boolean { + if (y < world.minHeight + 1 || y > world.maxHeight - 3) return false val ground: Block = world.getBlockAt(x, y - 1, z) val block: Block = world.getBlockAt(x, y, z) val above: Block = world.getBlockAt(x, y + 1, z) @@ -58,6 +60,18 @@ object SafeTeleportUtil { && twoAbove.type !in unsafeBlocks } + /** Validates a player-selected teleport point against current plot geometry and blocks. */ + fun isSafeTeleportSpawn(world: World, plot: PlotData, spawn: PlotTeleportSpawn): Boolean = + world.name.equals(plot.world, ignoreCase = true) && + plot.contains(spawn.x, spawn.z) && + isSafeBlock(world, spawn.x, spawn.y, spawn.z) + + private fun configuredSpawn(world: World, plot: PlotData): Location? { + val spawn = plot.teleportSpawn ?: return null + if (!isSafeTeleportSpawn(world, plot, spawn)) return null + return Location(world, spawn.x + 0.5, spawn.y.toDouble(), spawn.z + 0.5) + } + /** * Szuka bezpiecznej lokalizacji: * 1) Priorytet: poziomy wg kolejki buildYQueue (blisko baseY) @@ -69,7 +83,7 @@ object SafeTeleportUtil { baseY: Int, contains: (Int, Int) -> Boolean = { _, _ -> true } ): Location? { - val yMax = world.maxHeight - 2 + val yMax = world.maxHeight - 3 val yMin = world.minHeight + 1 val yQueue = buildYQueue(baseY, yMin, yMax) @@ -103,11 +117,13 @@ object SafeTeleportUtil { } /** - * Teleportuje gracza na bezpieczną lokalizację wg PlotData.y. + * Teleportuje gracza najpierw do zapisanego punktu działki, a jeśli ten nie jest już + * bezpieczny lub nie należy do geometrii, używa dotychczasowego wyszukiwania awaryjnego. */ fun safeTeleport(player: Player, plot: PlotData): Boolean { val world = Bukkit.getWorld(plot.world) ?: return false - val loc = findSafeLocation(world, plot.x, plot.z, plot.radius ?: 16, plot.y, plot::contains) + val loc = configuredSpawn(world, plot) + ?: findSafeLocation(world, plot.x, plot.z, plot.radius ?: 16, plot.y, plot::contains) return loc?.let { player.teleport(it) true diff --git a/src/main/resources/config.yml b/src/main/resources/config.yml index c7589d8..2f10cd8 100644 --- a/src/main/resources/config.yml +++ b/src/main/resources/config.yml @@ -8,11 +8,11 @@ config-version: 100 # If you would like to add your own language file, please contact us on Discord (https://discord.gg/Zk6mxv7eMh). language: "EN" -# auto: native text-entry dialogs when available; legacy: text entry through chat. +# auto: native text-entry dialogs when the server provides the Paper Dialog API; legacy: text entry through chat. interactions: mode: auto - # Set true when a proxy translates client protocols (e.g. ViaVersion/Geyser). - # Locally installed translators are detected automatically and use legacy UI. + # Set true only when you intentionally need chat fallback for translated/legacy clients. + # Installing ViaVersion, ViaBackwards or Geyser no longer disables dialogs globally. translated-clients: false # /********************************************/ diff --git a/src/main/resources/lang/messages_en.yml b/src/main/resources/lang/messages_en.yml index af1833d..f63cc7f 100644 --- a/src/main/resources/lang/messages_en.yml +++ b/src/main/resources/lang/messages_en.yml @@ -48,6 +48,8 @@ error: 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." + teleport_spawn_outside: "Stand inside your plot to set its teleport point." + teleport_spawn_unsafe: "This position is not safe enough to use as the plot teleport point." console: "Only players can use this command." @@ -67,6 +69,8 @@ plots: approaching_plot: "You are approaching plot . Protections will change soon." teleport: "Teleported to the plot!" teleport_failed: "No safe location found to teleport to this plot." + teleport_spawn_set: "Plot teleport point set to , , ." + teleport_spawn_failed: "Could not save the plot teleport point." rename_hint: "Type the new plot name in chat (you have 60 seconds)." rename_timeout: "Time to enter a new name has expired. Process cancelled." rename_success: "Plot name changed to: " @@ -157,6 +161,7 @@ members: removed: "Removed from the plot." GUI: + back: "Back to plot settings" claim: title_claim: "Confirm plot creation!" material_name: @@ -173,6 +178,7 @@ GUI: info: "Plot Information" flags: "Flag Settings" teleport: "Teleport to plot" + set_spawn: "Set teleport point" list: "Plot List" rename: "Rename Plot" expand: "Expand Plot" diff --git a/src/main/resources/lang/messages_pl.yml b/src/main/resources/lang/messages_pl.yml index b9f2231..05d89de 100644 --- a/src/main/resources/lang/messages_pl.yml +++ b/src/main/resources/lang/messages_pl.yml @@ -48,6 +48,8 @@ error: 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." + teleport_spawn_outside: "Aby ustawić punkt teleportacji, stań na swojej działce." + teleport_spawn_unsafe: "Ta pozycja nie jest wystarczająco bezpieczna jako punkt teleportacji działki." console: "Tylko gracz może użyć tej komendy." reload: "Wystąpił błąd podczas przeładowania konfiguracji: " @@ -66,6 +68,8 @@ plots: approaching_plot: "Zbliżasz się do działki . Ochrona za chwilę się zmieni." teleport: "Teleportowano na działkę!" teleport_failed: "Nie znaleziono bezpiecznego miejsca do teleportacji na tę działkę." + teleport_spawn_set: "Ustawiono punkt teleportacji działki na , , ." + teleport_spawn_failed: "Nie udało się zapisać punktu teleportacji działki." rename_hint: "Wpisz nową nazwę działki na czacie (masz 60 sekund)." rename_timeout: "Czas na wpisanie nowej nazwy minął. Anulowano zmianę nazwy." rename_success: "Zmieniono nazwę działki na: " @@ -157,6 +161,7 @@ members: removed: "Usunięto gracza z działki." GUI: + back: "Wróć do ustawień działki" claim: title_claim: "Potwierdź utworzenie działki!" material_name: @@ -173,6 +178,7 @@ GUI: info: "Informacje o działce" flags: "Ustawienia flag" teleport: "Teleportuj się do działki" + set_spawn: "Ustaw punkt teleportacji" list: "Lista działek" rename: "Zmień nazwę działki" expand: "Rozszerz działkę" @@ -442,7 +448,7 @@ flags: special-weapons: name: "Broń specjalna" description: "Pozwala obcym używać trójzębów i buzdyganów na działce." - not_allowed: "Nie możesz używać tej broni na tej działce." + not_allowed: "Nie możesz używać tej broni na działce." weather: name: "Blokowanie pogody i piorunów" description: "Blokuje uderzenia piorunów i zadawane przez nie obrażenia na działce." diff --git a/src/test/kotlin/pl/syntaxdevteam/plotsx/databases/PlotCacheLoaderTest.kt b/src/test/kotlin/pl/syntaxdevteam/plotsx/databases/PlotCacheLoaderTest.kt index b4a6246..8390b36 100644 --- a/src/test/kotlin/pl/syntaxdevteam/plotsx/databases/PlotCacheLoaderTest.kt +++ b/src/test/kotlin/pl/syntaxdevteam/plotsx/databases/PlotCacheLoaderTest.kt @@ -52,6 +52,7 @@ class PlotCacheLoaderTest { assertFalse(plot.contains(112, 15)) assertTrue(c.autoCommit) } + @Test fun `chunk offer loads persisted expansion level instead of guessing from area`() = databases { c, dialect -> DatabaseMigrations.migrate(c, dialect) c.createStatement().use { @@ -65,9 +66,19 @@ class PlotCacheLoaderTest { c.createStatement().use { it.execute("DELETE FROM plot_expansion_levels WHERE plot_id=1") } assertEquals(0, PlotCacheLoader.load(c, 1).plots.single().expansionLevel) } + @Test fun `corrupt price level fails cache load instead of silently changing the quote`() = databases { c, _ -> c.createStatement().use { it.execute("INSERT INTO plot_expansion_levels VALUES (1, -1)") } assertThrows(IllegalArgumentException::class.java) { PlotCacheLoader.load(c) } assertTrue(c.autoCommit) } + + @Test fun `targeted cache load includes persisted teleport spawn`() = databases { c, _ -> + PlotSpawnRepository.migrate(c) + c.createStatement().use { it.execute("INSERT INTO plot_spawns (plot_id, x, y, z) VALUES (1, 101, 70, -4)") } + val plot = PlotCacheLoader.load(c, 1).plots.single() + assertEquals(PlotTeleportSpawn(101, 70, -4), plot.teleportSpawn) + assertNull(PlotCacheLoader.load(c, 2).plots.single().teleportSpawn) + assertTrue(c.autoCommit) + } } diff --git a/src/test/kotlin/pl/syntaxdevteam/plotsx/databases/SqlBackupTest.kt b/src/test/kotlin/pl/syntaxdevteam/plotsx/databases/SqlBackupTest.kt index 0955bc5..262cc02 100644 --- a/src/test/kotlin/pl/syntaxdevteam/plotsx/databases/SqlBackupTest.kt +++ b/src/test/kotlin/pl/syntaxdevteam/plotsx/databases/SqlBackupTest.kt @@ -4,6 +4,7 @@ import org.junit.Assert.* import org.junit.Test import java.nio.file.Files import java.sql.DriverManager +import pl.syntaxdevteam.plotsx.identity.IdentityMigrationStore class SqlBackupTest { @Test fun roundTripAndMigration() { @@ -84,4 +85,52 @@ class SqlBackupTest { } } finally { directory.deleteRecursively() } } + + @Test fun `v5 backup preserves custom teleport spawn`() { + for (source in listOf("sqlite", "h2")) for (target in listOf("sqlite", "h2")) { + val directory = Files.createTempDirectory("plotsx-spawn-backup-test").toFile() + fun connect(type: String) = DriverManager.getConnection( + if (type == "sqlite") "jdbc:sqlite::memory:" else "jdbc:h2:mem:${java.util.UUID.randomUUID()}" + ) + try { + connect(source).use { conn -> + if (source == "sqlite") conn.createStatement().use { it.execute("PRAGMA foreign_keys=ON") } + conn.createStatement().use { statement -> + DatabaseSchema.statements(source).forEach { statement.execute(it) } + } + DatabaseMigrations.migrate(conn, source) + OperationJournal.migrate(conn) + IdentityMigrationStore.migrateSchema(conn) + PlotSpawnRepository.migrate(conn) + conn.createStatement().use { statement -> + statement.execute( + "INSERT INTO plots (plot_id, owner_uuid, x, z, y, radius, world, name, creation_time, geometry_type, geometry_revision) " + + "VALUES (42, 'owner', 10, 20, 64, 4, 'world', 'home', 12345, 'classic', 0)" + ) + statement.execute("INSERT INTO plot_segments (plot_id, x, z, radius) VALUES (42, 10, 20, 4)") + statement.execute("INSERT INTO plot_spawns (plot_id, x, y, z) VALUES (42, 12, 70, 18)") + } + val file = SqlBackup.export(conn, target, directory) + assertTrue(file.readLines().first().startsWith("-- PlotsX SQL backup v5")) + connect(target).use { restored -> + if (target == "sqlite") restored.createStatement().use { it.execute("PRAGMA foreign_keys=ON") } + restored.createStatement().use { statement -> + DatabaseSchema.statements(target).forEach { statement.execute(it) } + } + SqlBackup.restore(restored, target, file, allowChunkPlots = true) + restored.createStatement().use { statement -> + statement.executeQuery("SELECT x, y, z FROM plot_spawns WHERE plot_id=42").use { + assertTrue(it.next()) + assertEquals(12, it.getInt("x")) + assertEquals(70, it.getInt("y")) + assertEquals(18, it.getInt("z")) + } + } + } + } + } finally { + directory.deleteRecursively() + } + } + } }