Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 4 additions & 4 deletions docs/dialogs.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,23 +10,23 @@ 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

Automatyczne testy `DialogSessionsTest` obejmują stare formularze, obcego gracza, upływ czasu, rozłączenie, wyłączenie oraz równoczesne próby zatwierdzenia. `./gradlew test shadowJar` buduje artefakt bez wdrażania na serwer.

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.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,8 @@ data class PlotData @JvmOverloads constructor(
val extensions: List<PlotSegment> = emptyList(),
val chunks: Set<ChunkPosition> = 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" }
Expand Down
Original file line number Diff line number Diff line change
@@ -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<Int, PlotTeleportSpawn> {
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
}
}
}
27 changes: 21 additions & 6 deletions src/main/kotlin/pl/syntaxdevteam/plotsx/databases/SqlBackup.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand All @@ -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;") }
Expand Down Expand Up @@ -114,21 +124,24 @@ 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") {
"Not a PlotsX backup for $dialect. Export using the target database dialect."
}
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." }
Expand All @@ -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) }
}
Expand All @@ -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.
Expand Down
20 changes: 20 additions & 0 deletions src/main/kotlin/pl/syntaxdevteam/plotsx/gui/ChunkExpandGUI.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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}"
Expand Down Expand Up @@ -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, {
Expand All @@ -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
Expand Down
Loading
Loading