Skip to content

Commit ae23872

Browse files
committed
feat(expansion): implement direct chunk expansion command for adjacent plots
1 parent d7c12ec commit ae23872

8 files changed

Lines changed: 99 additions & 8 deletions

File tree

‎docs/commands.md‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ Lista odpowiada aktualnej implementacji. `[argument]` jest opcjonalny, a `<argum
1010
| `/unclaim` | `plotsx.cmd.unclaim` | Otwiera potwierdzenie usunięcia własnej działki, na której stoi gracz. |
1111
| `/plot` | `plotsx.cmd.plot` | Otwiera panel działki pod graczem, jeśli jest jej właścicielem, członkiem lub ma dostęp administracyjny; poza działką pokazuje listę własnych działek, również dla OP. |
1212
| `/plot <nazwa>` | `plotsx.cmd.plot` | Otwiera panel dostępnej działki wyszukanej po nazwie; własna ma pierwszeństwo. |
13+
| `/plot expand` | `plotsx.cmd.plot` + `plotsx.plot.expand` | Stojąc na wolnym chunku przylegającym bokiem do własnej działki chunkowej, otwiera bezpośrednio potwierdzenie jego zakupu. |
1314

1415
Rodzaj nowej działki wybiera administrator przez `plots.claiming.mode: classic | chunks`.
1516
W trybie `classic` `/claim` używa `plots.radius`; w trybie `chunks` zajmuje cały
@@ -52,6 +53,7 @@ plots:
5253
expansion:
5354
price: 500.0
5455
priceMultiplier: 1.5
56+
standingCommand: true
5557
expansion:
5658
price: 500.0
5759
priceMultiplier: 1.5
@@ -65,6 +67,12 @@ otrzymuje jeden wolny chunk przylegający bokiem do chunka, w którym stoi wła
6567
Oba warianty są obsługiwane przez GUI ekwipunkowe. Natywne dialogi są zarezerwowane
6668
dla operacji wymagających wpisania tekstu, takich jak zmiana nazwy działki.
6769

70+
Dla działek chunkowych alternatywą dla wyboru kierunku w panelu jest `/plot expand`:
71+
właściciel staje na docelowym wolnym chunku, a komenda otwiera od razu potwierdzenie
72+
zakupu. `plots.chunks.expansion.standingCommand: false` wyłącza ten wariant. Jeśli
73+
docelowy chunk dotyka kilku działek tego samego właściciela, trzeba użyć panelu
74+
konkretnej działki, aby wybór był jednoznaczny.
75+
6876
Zakup chunkowy używa osobnego cennika `plots.chunks.expansion`. Cena następnego
6977
chunka to `price × priceMultiplier ^ liczba_dokupionych_chunków`. Obowiązują
7078
`maxPerPlot`, `maxTotalOwned`, odpowiadające im uprawnienia liczbowe, wspólny limit

‎src/main/kotlin/pl/syntaxdevteam/plotsx/commands/PlotCMD.kt‎

Lines changed: 32 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,9 @@ import io.papermc.paper.command.brigadier.BasicCommand
44
import io.papermc.paper.command.brigadier.CommandSourceStack
55
import org.bukkit.entity.Player
66
import pl.syntaxdevteam.plotsx.PlotsX
7+
import pl.syntaxdevteam.plotsx.geometry.ChunkGeometry
8+
import pl.syntaxdevteam.plotsx.geometry.ChunkPosition
9+
import pl.syntaxdevteam.plotsx.gui.ChunkExpandGUI
710
import pl.syntaxdevteam.plotsx.gui.MembersGUI
811
import pl.syntaxdevteam.plotsx.gui.PlotGUI
912
import pl.syntaxdevteam.plotsx.gui.PlotListGUI
@@ -48,6 +51,10 @@ class PlotCMD(private val plugin: PlotsX) : BasicCommand {
4851
val player = sender as? Player ?: run { members.reply(sender, "admin_usage"); return }
4952
if (!PermissionChecker.canManagePlot(player)) { members.reply(player, "denied"); return }
5053
val standing = plugin.databaseHandler.getPlotAtLocation(player.world.name, player.location.blockX, player.location.blockZ)
54+
if (args.firstOrNull().equals("expand", true)) {
55+
expandStandingChunk(player, args)
56+
return
57+
}
5158
if (args.firstOrNull()?.lowercase() in actions) {
5259
if (standing == null) { members.reply(player, "stand_on_plot"); return }
5360
if (args.size == 1 && args[0].equals("members", true) && access.canOpen(player, standing)) {
@@ -84,7 +91,9 @@ class PlotCMD(private val plugin: PlotsX) : BasicCommand {
8491
it.world == p.world.name && it.contains(p.location.blockX, p.location.blockZ)
8592
} }
8693
val candidates = when {
87-
words.size <= 1 -> actions + (if (!admin && access.admin(sender)) listOf("admin") else emptyList())
94+
words.size <= 1 -> actions + (if (!admin && plugin.config.getBoolean(
95+
"plots.chunks.expansion.standingCommand", true)) listOf("expand") else emptyList()) +
96+
(if (!admin && access.admin(sender)) listOf("admin") else emptyList())
8897
plot == null || !access.canOpen(sender, plot) -> emptyList()
8998
words.size == 2 && words[0] == "permission" -> if (access.owner(sender, plot)) access.roles else emptyList()
9099
words.size == 3 && words[0] == "permission" -> access.actions
@@ -99,4 +108,26 @@ class PlotCMD(private val plugin: PlotsX) : BasicCommand {
99108
}
100109
return candidates.filter { it.startsWith(words.lastOrNull().orEmpty(), true) }.distinct().sorted()
101110
}
111+
112+
private fun expandStandingChunk(player: Player, args: Array<String>) {
113+
fun error(key: String) = player.sendMessage(plugin.messageHandler.stringMessageToComponent("error", key))
114+
if (args.size != 1) { error("chunk_expand_command_usage"); return }
115+
if (!plugin.config.getBoolean("plots.chunks.expansion.standingCommand", true)) {
116+
error("chunk_expand_command_disabled"); return
117+
}
118+
if (!PermissionChecker.canExpandPlot(player)) { error("no_permission"); return }
119+
val target = ChunkPosition.atBlock(player.location.blockX, player.location.blockZ)
120+
if (plugin.cacheManager.getCachedPlots().any {
121+
it.world.equals(player.world.name, true) && it.geometry.contains(player.location.blockX, player.location.blockZ)
122+
}) { error("chunk_expand_target_owned"); return }
123+
val candidates = plugin.cacheManager.getCachedPlots().filter { plot ->
124+
plot.ownerUuid == player.uniqueId && plot.world.equals(player.world.name, true) &&
125+
(plot.geometry as? ChunkGeometry)?.expansionTo(target) != null
126+
}
127+
when (candidates.size) {
128+
0 -> error("chunk_expand_no_adjacent")
129+
1 -> plugin.guiHandler.registerGui(player, ChunkExpandGUI(plugin, candidates.single().id, target))
130+
else -> error("chunk_expand_ambiguous")
131+
}
132+
}
102133
}

‎src/main/kotlin/pl/syntaxdevteam/plotsx/geometry/ChunkGeometry.kt‎

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,8 @@ data class ChunkPosition(val x: Int, val z: Int) {
3333

3434
/** A nonempty, side-connected set of complete chunks; holes inside its bounds remain unclaimed. */
3535
class ChunkGeometry(chunks: Collection<ChunkPosition>) : PlotGeometry {
36+
data class Expansion(val source: ChunkPosition, val direction: ExpansionDirection, val target: ChunkPosition)
37+
3638
val chunks: Set<ChunkPosition> = Collections.unmodifiableSet(LinkedHashSet(chunks))
3739

3840
init {
@@ -70,4 +72,18 @@ class ChunkGeometry(chunks: Collection<ChunkPosition>) : PlotGeometry {
7072
if (source !in chunks) return null
7173
return source.neighbour(direction)?.takeUnless { it in chunks }
7274
}
75+
76+
/** Resolves a free chunk selected by standing on it to an expansion from this plot. */
77+
fun expansionTo(target: ChunkPosition): Expansion? {
78+
if (target in chunks) return null
79+
for (direction in ExpansionDirection.entries) {
80+
val sourceX = target.x.toLong() - direction.dx
81+
val sourceZ = target.z.toLong() - direction.dz
82+
if (sourceX !in ChunkPosition.MIN_COORDINATE.toLong()..ChunkPosition.MAX_COORDINATE.toLong() ||
83+
sourceZ !in ChunkPosition.MIN_COORDINATE.toLong()..ChunkPosition.MAX_COORDINATE.toLong()) continue
84+
val source = ChunkPosition(sourceX.toInt(), sourceZ.toInt())
85+
if (source in chunks) return Expansion(source, direction, target)
86+
}
87+
return null
88+
}
7389
}

‎src/main/kotlin/pl/syntaxdevteam/plotsx/gui/ChunkExpandGUI.kt‎

Lines changed: 19 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,11 @@ import java.math.BigDecimal
1414
import java.util.UUID
1515

1616
/** Chunk offers use cached data. All SQL in the purchase is performed by a worker. */
17-
internal class ChunkExpandGUI(private val plugin: PlotsX, private val plotId: Int) : AbstractGUI(
17+
internal class ChunkExpandGUI(
18+
private val plugin: PlotsX,
19+
private val plotId: Int,
20+
private val requestedTarget: ChunkPosition? = null
21+
) : AbstractGUI(
1822
plugin.messageHandler.stringMessageToComponentNoPrefix("GUI", "expand.title"), 27
1923
) {
2024
private val directions = mapOf(4 to ExpansionDirection.NORTH, 14 to ExpansionDirection.EAST,
@@ -37,9 +41,16 @@ internal class ChunkExpandGUI(private val plugin: PlotsX, private val plotId: In
3741
val plot = plugin.cacheManager.getPlot(plotId) ?: return null
3842
val geometry = plot.geometry as? ChunkGeometry ?: return null
3943
offered = plot
40-
source = if (player.world.name == plot.world) ChunkPosition.atBlock(player.location.blockX, player.location.blockZ)
41-
.takeIf { it in geometry.chunks } else null
42-
target = source?.let { geometry.expansion(direction, it) }
44+
if (requestedTarget != null) {
45+
val expansion = geometry.expansionTo(requestedTarget) ?: return null
46+
source = expansion.source
47+
direction = expansion.direction
48+
target = expansion.target
49+
} else {
50+
source = if (player.world.name == plot.world) ChunkPosition.atBlock(player.location.blockX, player.location.blockZ)
51+
.takeIf { it in geometry.chunks } else null
52+
target = source?.let { geometry.expansion(direction, it) }
53+
}
4354
price = ExpansionEconomy.chunkPrice(plugin, plot.expansionLevel)
4455
operationId = UUID.randomUUID()
4556
return plot
@@ -66,7 +77,7 @@ internal class ChunkExpandGUI(private val plugin: PlotsX, private val plotId: In
6677
event.isCancelled = true
6778
val player = event.whoClicked as? Player ?: return
6879
if (submitted) return
69-
directions[event.rawSlot]?.let {
80+
directions[event.rawSlot]?.takeIf { requestedTarget == null }?.let {
7081
direction = it
7182
player.scheduler.run(plugin, {
7283
if (!submitted && player.openInventory.topInventory === inventory) open(player)
@@ -101,8 +112,9 @@ internal class ChunkExpandGUI(private val plugin: PlotsX, private val plotId: In
101112
if (!player.isOnline || !PermissionChecker.canExpandPlot(player) || owner != plot.ownerUuid) return false
102113
val current = plugin.cacheManager.getPlot(plotId) ?: return false
103114
if (current.ownerUuid != owner || current.geometryRevision != plot.geometryRevision || current.radius != null ||
104-
current.expansionLevel != plot.expansionLevel || player.world.name != plot.world ||
105-
ChunkPosition.atBlock(player.location.blockX, player.location.blockZ) != from) return false
115+
current.expansionLevel != plot.expansionLevel || player.world.name != plot.world) return false
116+
val standing = ChunkPosition.atBlock(player.location.blockX, player.location.blockZ)
117+
if ((requestedTarget == null && standing != from) || (requestedTarget != null && standing != to)) return false
106118
if (ExpansionEconomy.chunkPrice(plugin, current.expansionLevel)?.compareTo(amount) != 0 || limits(player) != limits) return false
107119
return plugin.regionProtectionHook?.overlapsBounds(player.world, to.bounds) != true
108120
}

‎src/main/resources/config.yml‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,9 @@ plots:
4646
# Price per additional chunk; the first claimed chunk remains free.
4747
price: 500.0
4848
priceMultiplier: 1.5
49+
# Allow /plot expand while standing on a free chunk adjacent to your chunk plot.
50+
# The command skips direction selection and opens the purchase confirmation directly.
51+
standingCommand: true
4952
# Maximum plots across ALL worlds; override with plotsx.plot.max-plots.<number>.
5053
# Highest granted numeric permission wins; without one, this value is used.
5154
maxPlots: 5

‎src/main/resources/lang/messages_en.yml‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,11 @@ prefix: "<gradient:#11ff00:#d4ffde:#70e672:#d4ffde:#11ff00>[<b>PlotsX</b>]</grad
1111
error:
1212
chunk_purchase_busy: "Another plot operation is in progress. Please retry shortly."
1313
chunk_purchase_review: "Payment requires review. Operation: <operation>"
14+
chunk_expand_command_usage: "<yellow>Usage: /plot expand — stand on a free chunk next to your plot.</yellow>"
15+
chunk_expand_command_disabled: "<red>Expansion by standing on the target chunk is disabled.</red>"
16+
chunk_expand_target_owned: "<red>The chunk you are standing on is already claimed.</red>"
17+
chunk_expand_no_adjacent: "<red>This chunk does not share a side with any of your chunk plots.</red>"
18+
chunk_expand_ambiguous: "<red>This chunk touches more than one of your plots. Use the selected plot's menu.</red>"
1419
chunk_expansion_pending: "Chunk expansion is not available in this implementation stage."
1520
claim_chunk_limit: "The per-plot or owner chunk limit has been reached."
1621
expand_invalid_price: "<red>Invalid expansion price in configuration.</red>"

‎src/main/resources/lang/messages_pl.yml‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,11 @@ prefix: "<gradient:#11ff00:#d4ffde:#70e672:#d4ffde:#11ff00>[<b>PlotsX</b>]</grad
1111
error:
1212
chunk_purchase_busy: "Trwa inna operacja działki. Spróbuj ponownie za chwilę."
1313
chunk_purchase_review: "Płatność wymaga wyjaśnienia. Operacja: <operation>"
14+
chunk_expand_command_usage: "<yellow>Użycie: /plot expand — stań na wolnym chunku obok swojej działki.</yellow>"
15+
chunk_expand_command_disabled: "<red>Rozszerzanie przez stanie na docelowym chunku jest wyłączone.</red>"
16+
chunk_expand_target_owned: "<red>Chunk, na którym stoisz, jest już zajęty.</red>"
17+
chunk_expand_no_adjacent: "<red>Ten chunk nie przylega bokiem do żadnej Twojej działki chunkowej.</red>"
18+
chunk_expand_ambiguous: "<red>Ten chunk przylega do kilku Twoich działek. Użyj panelu wybranej działki.</red>"
1419
chunk_expansion_pending: "Rozszerzanie działek chunkowych nie jest jeszcze dostępne na tym etapie wdrożenia."
1520
claim_chunk_limit: "Osiągnięto limit chunków działki lub właściciela."
1621
expand_invalid_price: "<red>Nieprawidłowa cena rozszerzenia w konfiguracji.</red>"

‎src/test/kotlin/pl/syntaxdevteam/plotsx/geometry/ChunkGeometryTest.kt‎

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -122,6 +122,17 @@ class ChunkGeometryTest {
122122
assertNull(plot.expansion(ExpansionDirection.EAST, ChunkPosition(5, 5)))
123123
}
124124

125+
@Test fun `standing target resolves its adjacent source and direction`() {
126+
val plot = ChunkGeometry(listOf(ChunkPosition(0, 0), ChunkPosition(1, 0)))
127+
val expansion = plot.expansionTo(ChunkPosition(2, 0))
128+
assertEquals(ChunkPosition(1, 0), expansion?.source)
129+
assertEquals(ExpansionDirection.EAST, expansion?.direction)
130+
assertEquals(ChunkPosition(2, 0), expansion?.target)
131+
assertNull(plot.expansionTo(ChunkPosition(1, 0)))
132+
assertNull(plot.expansionTo(ChunkPosition(3, 0)))
133+
assertNull(plot.expansionTo(ChunkPosition(2, 1)))
134+
}
135+
125136
@Test fun `integer block extremes are represented exactly and outward expansion is rejected`() {
126137
val low = ChunkPosition.atBlock(Int.MIN_VALUE, Int.MIN_VALUE)
127138
val high = ChunkPosition.atBlock(Int.MAX_VALUE, Int.MAX_VALUE)

0 commit comments

Comments
 (0)