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
15 changes: 15 additions & 0 deletions docs/plans/chunk-claims/11-zakup-chunka-i-gui.md
Original file line number Diff line number Diff line change
Expand Up @@ -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ń.
Original file line number Diff line number Diff line change
@@ -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)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -86,4 +86,13 @@ class ChunkGeometry(chunks: Collection<ChunkPosition>) : 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()
}
}
54 changes: 40 additions & 14 deletions src/main/kotlin/pl/syntaxdevteam/plotsx/gui/ChunkExpandGUI.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
) {
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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. */
Expand Down Expand Up @@ -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")
Expand All @@ -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)
}
}
})
})
}
Expand Down
Loading
Loading