feat: migrate shops when an AreaShop-OG plot is transferred

Transferring a plot moved the AreaShop region and its WorldGuard
membership to the new owner, but the QuickShop shops inside kept the
previous owner. The new holder could not manage them and the old owner
still collected every sale on a plot they no longer held.

Listen for AreaShop-OG's new TransferredRegionEvent and hand the shops
in the region to QuickShop-OG's ShopManager#transferShopsOwnership,
leaving stock in place. When the running QuickShop has no such method
the count of orphaned shops is reported instead of failing silently.

Replace the vendored libs/AreaShop-2.9.1.jar with a git submodule at
libs/AreaShop-OG, following the Template-OG layout. The jar was upstream
AreaShop, not AreaShop-OG, so newly added AreaShop-OG API was invisible
to the compile classpath. AreaShop-OG is a multi-project build, so it
joins as a composite build via includeBuild.

Also prefer QuickShop's chunk coordinate shop lookup over the Chunk
overload, so scanning a region no longer forces its chunks to load, and
walk the region's chunk range inclusively instead of overshooting by one
chunk column.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
NotAlexNoyleandClaude Opus 5 committed 2026-08-10 16:53:04 -04:00
1 parent f3eb494984
commit b1d58052e4
8 files changed
+111 -30

No files matched your search

+4
View File
@@ -0,0 +1,4 @@
[submodule "libs/AreaShop-OG"]
path = libs/AreaShop-OG
url = https://github.com/true-og/AreaShop-OG
branch = dev/bleeding
Executable
+4
View File
@@ -0,0 +1,4 @@
#!/usr/bin/bash
# Fetch all submodule content.
git submodule update --force --recursive --init
+1 -1
View File
@@ -58,7 +58,7 @@ repositories {
dependencies {
compileOnly("org.purpurmc.purpur:purpur-api:1.19.4-R0.1-SNAPSHOT") // Declare Purpur API version to be packaged.
compileOnly("com.sk89q.worldguard:worldguard-bukkit:7.0.8") // Import WorldGuard API.
compileOnly(files("libs/AreaShop-2.9.1.jar")) // Import AreaShop-OG compatible upstream API.
compileOnly("me.wiefferink:areashop:1.0") // Import AreaShop-OG API (from the libs/ submodule).
}
/* ---------------------- Reproducible jars ---------------------------- */
Binary file not shown.
+1
Submodule libs/AreaShop-OG added at 8984508336.
+6
View File
@@ -1,3 +1,9 @@
plugins { id("org.gradle.toolchains.foojay-resolver-convention") version "1.0.0" }
rootProject.name = "ShopBridge-OG"
ProcessBuilder("sh", "bootstrap.sh").directory(rootDir).inheritIO().start().let {
if (it.waitFor() != 0) throw GradleException("bootstrap.sh failed")
}
includeBuild("libs/AreaShop-OG") // Import AreaShop-OG multi-project build (from source).
+62 -29
View File
@@ -1,10 +1,11 @@
import java.util.UUID
import java.util.logging.Level
import me.wiefferink.areashop.events.notify.DeletedRegionEvent
import me.wiefferink.areashop.events.notify.ResoldRegionEvent
import me.wiefferink.areashop.events.notify.SoldRegionEvent
import me.wiefferink.areashop.events.notify.TransferredRegionEvent
import me.wiefferink.areashop.events.notify.UnrentedRegionEvent
import me.wiefferink.areashop.regions.GeneralRegion
import org.bukkit.Chunk
import org.bukkit.Location
import org.bukkit.event.EventHandler
import org.bukkit.event.EventPriority
@@ -57,35 +58,39 @@ class Main : JavaPlugin(), Listener {
removeShopsIn(event.region, wipeStock = false)
}
// A player handed the plot to another player, so the shops follow the plot.
@EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true)
fun onRegionTransferred(event: TransferredRegionEvent) {
transferShopsIn(event.region, event.toPlayer ?: return)
}
private fun transferShopsIn(region: GeneralRegion, newOwner: UUID) {
val shops = collectShopsIn(region)
if (shops.isEmpty()) {
return
}
val handled =
try {
quickShop.transferShopsOwnership(shops.values, newOwner)
} catch (error: Exception) {
logger.warning("Could not transfer shops in region ${region.name}: ${error.message}")
return
}
if (!handled) {
logger.warning(
"${QuickShopAdapter.PLUGIN_NAME} has no ownership transfer API, " +
"${shops.size} shop(s) in region ${region.name} still belong to the previous owner."
)
return
}
logger.info("Transferred ${shops.size} shop(s) in region ${region.name} to $newOwner.")
}
private fun removeShopsIn(region: GeneralRegion, wipeStock: Boolean) {
val protectedRegion = region.region ?: return
val world = region.world ?: return
val minPoint = region.minimumPoint
val maxPoint = region.maximumPoint
val chuckLocations = hashSetOf<Chunk>()
var x = minPoint.blockX
while (x <= maxPoint.blockX + 16) {
var z = minPoint.blockZ
while (z <= maxPoint.blockZ + 16) {
chuckLocations.add(world.getChunkAt(x shr 4, z shr 4))
z += 16
}
x += 16
}
val shopMap = hashMapOf<Location, Any>()
for (chunk in chuckLocations) {
shopMap.putAll(quickShop.getShops(chunk))
}
for ((shopLocation, shop) in shopMap) {
if (!protectedRegion.contains(shopLocation.blockX, shopLocation.blockY, shopLocation.blockZ)) {
continue
}
for ((shopLocation, shop) in collectShopsIn(region)) {
// Empty the container first, because the shop's inventory link is no longer resolvable after deletion.
if (wipeStock) {
try {
@@ -100,4 +105,32 @@ class Main : JavaPlugin(), Listener {
quickShop.deleteShop(shop)
}
}
// Every shop inside the region's WorldGuard boundary, keyed by its location.
private fun collectShopsIn(region: GeneralRegion): Map<Location, Any> {
val protectedRegion = region.region ?: return emptyMap()
val world = region.world ?: return emptyMap()
val minPoint = region.minimumPoint
val maxPoint = region.maximumPoint
val minChunkX = minPoint.blockX shr 4
val maxChunkX = maxPoint.blockX shr 4
val minChunkZ = minPoint.blockZ shr 4
val maxChunkZ = maxPoint.blockZ shr 4
val shopMap = hashMapOf<Location, Any>()
for (chunkX in minChunkX..maxChunkX) {
for (chunkZ in minChunkZ..maxChunkZ) {
// Prefer the coordinate lookup, the Chunk overload would load the chunk.
val shops =
quickShop.getShops(world.name, chunkX, chunkZ)
?: quickShop.getShops(world.getChunkAt(chunkX, chunkZ))
shopMap.putAll(shops)
}
}
return shopMap.filterKeys { location ->
protectedRegion.contains(location.blockX, location.blockY, location.blockZ)
}
}
}
+33
View File
@@ -1,5 +1,6 @@
import java.lang.reflect.InvocationTargetException
import java.lang.reflect.Method
import java.util.UUID
import org.bukkit.Chunk
import org.bukkit.Location
import org.bukkit.plugin.Plugin
@@ -9,6 +10,18 @@ internal class QuickShopAdapter private constructor(private val shopManager: Any
shopManager.javaClass.methods.findMethod("getShops", Chunk::class.java)
?: throw IllegalStateException("QuickShop-OG does not expose ShopManager#getShops(Chunk).")
// Chunk coordinate lookup, preferred because it never forces a chunk to load.
private val getShopsByCoordinateMethod =
shopManager.javaClass.methods.findMethod(
"getShops",
String::class.java,
Int::class.javaPrimitiveType!!,
Int::class.javaPrimitiveType!!,
)
private val transferShopsOwnershipMethod =
shopManager.javaClass.methods.findMethod("transferShopsOwnership", Collection::class.java, UUID::class.java)
private val managerDeleteShopMethod =
shopManager.javaClass.methods.firstOrNull { method ->
method.name == "deleteShop" && method.parameterCount == 1
@@ -21,6 +34,26 @@ internal class QuickShopAdapter private constructor(private val shopManager: Any
fun getShops(chunk: Chunk): Map<Location, Any> {
val shops = getShopsMethod.call(shopManager, chunk) as? Map<*, *> ?: return emptyMap()
return toShopMap(shops)
}
// Returns null when this QuickShop build has no coordinate based lookup.
fun getShops(world: String, chunkX: Int, chunkZ: Int): Map<Location, Any>? {
val method = getShopsByCoordinateMethod ?: return null
val shops = method.call(shopManager, world, chunkX, chunkZ) as? Map<*, *> ?: return emptyMap()
return toShopMap(shops)
}
// True when the shops were handed to QuickShop, false when unsupported.
fun transferShopsOwnership(shops: Collection<Any>, newOwner: UUID): Boolean {
val method = transferShopsOwnershipMethod ?: return false
method.call(shopManager, shops, newOwner)
return true
}
private fun toShopMap(shops: Map<*, *>): Map<Location, Any> {
return shops.entries.associate { entry ->
val location =
entry.key as? Location