feat: info

This commit is contained in:
Keisuke Hirata 2025-12-09 12:43:55 +09:00
parent 32e4641e24
commit 1c809b3def
7 changed files with 237 additions and 12 deletions

3
.gitmodules vendored
View File

@ -2,3 +2,6 @@
path = hcu-core
url = git@gitea.hareworks.net:hcu/hcu-core.git
branch = master
[submodule "GhostDisplays"]
path = GhostDisplays
url = git@gitea.hareworks.net:Hare/GhostDisplays.git

1
GhostDisplays Submodule

@ -0,0 +1 @@
Subproject commit be85d83428eff52a749747fc495c4bda0e82d035

View File

@ -2,5 +2,5 @@ rootProject.name = "faction"
includeBuild("hcu-core")
includeBuild("hcu-core/kommand-lib")
includeBuild("hcu-core/kommand-lib/permits-lib")
includeBuild("GhostDisplays")

View File

@ -384,6 +384,34 @@ class LandsCommand(
}
}
}
literal("info") {
string("name") {
executes {
val input: String = argument("name")
val land = resolveLand(sender, input) ?: return@executes
printLandInfo(sender, land, app)
}
}
}
literal("info-here") {
executes {
val player = sender as? Player ?: return@executes
val service = app.landService
if (service == null) {
sender.sendMessage(Component.text("Service not ready.", NamedTextColor.RED))
return@executes
}
val land = service.getLandAt(player.world.name, player.location.x, player.location.y, player.location.z)
if (land == null) {
sender.sendMessage(Component.text("There is no land at your position.", NamedTextColor.RED))
} else {
printLandInfo(sender, land, app)
}
}
}
literal("visualize") {
executes {
@ -426,7 +454,8 @@ class LandsCommand(
}
service.modifyLand(land.id) { data ->
action(data.parts)
@Suppress("UNCHECKED_CAST")
action(data.parts as MutableList<Shape>)
}
}
@ -472,4 +501,30 @@ class LandsCommand(
return null
}
private fun printLandInfo(sender: CommandSender, land: Land, app: App) {
sender.sendMessage(Component.text("----- Land Info -----", NamedTextColor.GOLD))
sender.sendMessage(Component.text("Name: ", NamedTextColor.GRAY).append(Component.text(land.name, NamedTextColor.WHITE)))
sender.sendMessage(Component.text("ID: ", NamedTextColor.GRAY).append(Component.text(land.id, NamedTextColor.WHITE)))
sender.sendMessage(Component.text("Owner (Actor ID): ", NamedTextColor.GRAY).append(Component.text(land.actorId, NamedTextColor.WHITE)))
sender.sendMessage(Component.text("World: ", NamedTextColor.GRAY).append(Component.text(land.world, NamedTextColor.WHITE)))
sender.sendMessage(Component.text("Calculating total volume...", NamedTextColor.GRAY))
app.landService?.getAsyncVolume(land)?.thenAccept { totalVolume ->
sender.sendMessage(Component.text("Total Blocks (Exact): ", NamedTextColor.GRAY).append(Component.text(totalVolume, NamedTextColor.WHITE)))
}?.exceptionally { ex ->
sender.sendMessage(Component.text("Error calculating volume: ${ex.message}", NamedTextColor.RED))
null
}
sender.sendMessage(Component.text("Parts (${land.data.parts.size}):", NamedTextColor.GRAY))
land.data.parts.forEachIndexed { index, shape ->
val description = when (shape) {
is Shape.Cuboid -> "Cuboid (Size: ${shape.volume()})"
is Shape.Cylinder -> "Cylinder (R=${shape.radius}, H=${shape.totalHeight()})"
}
sender.sendMessage(Component.text(" $index: $description", NamedTextColor.WHITE))
}
sender.sendMessage(Component.text("---------------------", NamedTextColor.GOLD))
}
}

View File

@ -10,11 +10,100 @@ data class Land(
val actorId: Int,
val world: String,
val data: LandData
)
) {
fun totalVolume(): Long = data.parts.sumOf { it.volume() }
fun calculateExactVolume(): Long {
if (data.parts.isEmpty()) return 0
if (data.parts.size == 1) return data.parts[0].volume()
// 1. Collect all Y boundaries to split into vertical slices
val yBoundaries = sortedSetOf<Int>()
data.parts.forEach { shape ->
when (shape) {
is Shape.Cuboid -> {
yBoundaries.add(shape.minY())
yBoundaries.add(shape.maxY() + 1)
}
is Shape.Cylinder -> {
yBoundaries.add(shape.y - shape.bottomHeight)
yBoundaries.add(shape.y + shape.topHeight + 1)
}
}
}
var totalVolume = 0L
val yList = yBoundaries.toList()
// 2. Iterate through Y intervals (slices)
for (i in 0 until yList.size - 1) {
val yStart = yList[i]
val yEnd = yList[i+1] // Exclusive
val height = (yEnd - yStart).toLong()
// Use generating a midpoint Y for checking containment
// (Actually just yStart + 0.5 is sufficient as shapes are block-aligned)
val checkY = yStart.toDouble() + 0.5
// Filter shapes active in this Y slice
val activeShapes = data.parts.filter { shape ->
when (shape) {
is Shape.Cuboid -> checkY >= shape.minY() && checkY <= shape.maxY() + 1.0
is Shape.Cylinder -> checkY >= (shape.y - shape.bottomHeight) && checkY <= (shape.y + shape.topHeight + 1.0)
}
}
if (activeShapes.isEmpty()) continue
// 3. Scan XZ plane for this interval
// Determine bounding box for active shapes to limit scan area
var minX = Int.MAX_VALUE
var minZ = Int.MAX_VALUE
var maxX = Int.MIN_VALUE
var maxZ = Int.MIN_VALUE
activeShapes.forEach { shape ->
when (shape) {
is Shape.Cuboid -> {
minX = minOf(minX, shape.minX())
maxX = maxOf(maxX, shape.maxX())
minZ = minOf(minZ, shape.minZ())
maxZ = maxOf(maxZ, shape.maxZ())
}
is Shape.Cylinder -> {
val r = (shape.radius + 0.5).toInt() + 1
minX = minOf(minX, shape.x - r)
maxX = maxOf(maxX, shape.x + r)
minZ = minOf(minZ, shape.z - r)
maxZ = maxOf(maxZ, shape.z + r)
}
}
}
var area = 0L
// Iterate all blocks in the bounding box of this slice
for (x in minX..maxX) {
for (z in minZ..maxZ) {
val cx = x + 0.5
val cz = z + 0.5
// If any shape contains this block, it contributes to area
// We pass checkY which is valid for all activeShapes
if (activeShapes.any { it.contains(cx, checkY, cz) }) {
area++
}
}
}
totalVolume += area * height
}
return totalVolume
}
}
@Serializable
data class LandData(
val parts: MutableList<Shape> = mutableListOf()
val parts: List<Shape> = emptyList()
)
@Serializable
@ -32,7 +121,7 @@ sealed class Shape {
fun minZ() = minOf(z1, z2)
fun maxZ() = maxOf(z1, z2)
fun contains(x: Double, y: Double, z: Double): Boolean {
override fun contains(x: Double, y: Double, z: Double): Boolean {
// Add 0.5 to block coordinates for center-based comparison
return x >= minX() + 0.5 && x <= maxX() + 0.5 &&
y >= minY() + 0.5 && y <= maxY() + 0.5 &&
@ -47,6 +136,13 @@ sealed class Shape {
val maxCorner = Triple(maxX(), maxY(), maxZ())
return minCorner to maxCorner
}
override fun volume(): Long {
val width = (maxX() - minX() + 1).toLong()
val height = (maxY() - minY() + 1).toLong()
val depth = (maxZ() - minZ() + 1).toLong()
return width * height * depth
}
}
@Serializable
@ -57,23 +153,23 @@ sealed class Shape {
val bottomHeight: Int, // Height extending downward from center block
val topHeight: Int // Height extending upward from center block
) : Shape() {
fun contains(tx: Double, ty: Double, tz: Double): Boolean {
override fun contains(x: Double, y: Double, z: Double): Boolean {
// Add 0.5 to block coordinates for center-based comparison
val centerX = x + 0.5
val centerY = y + 0.5
val centerZ = z + 0.5
val centerX = this.x + 0.5
val centerY = this.y + 0.5
val centerZ = this.z + 0.5
// Total height includes center block (1) + bottomHeight + topHeight
val minY = centerY - bottomHeight
val maxY = centerY + 1.0 + topHeight
if (ty < minY || ty > maxY) return false
if (y < minY || y > maxY) return false
// Add 0.5 to radius for block-aligned judgment
// This ensures that a radius of 4 includes all blocks within 4 blocks from center
val actualRadius = radius + 0.5
val dx = tx - centerX
val dz = tz - centerZ
val dx = x - centerX
val dz = z - centerZ
return (dx * dx + dz * dz) <= (actualRadius * actualRadius)
}
@ -204,7 +300,53 @@ sealed class Shape {
return outline
}
override fun volume(): Long {
// Optimized O(R) calculation using quadrant symmetry
// We calculate one quadrant (x > 0, z > 0) and multiply by 4.
// Then we add the axes (x=0 line and z=0 line) separately.
val actualRadius = radius + 0.5
val radiusSq = actualRadius * actualRadius
val rInt = actualRadius.toInt() + 1
var quadrantBlocks = 0L
// Loop for x from 1 to rInt (Positive X)
for (dx in 1..rInt) {
val remainingSq = radiusSq - (dx * dx)
if (remainingSq < 0) break // Should not happen with loop bounds, but safe
// Max integer dz for this dx such that dx^2 + dz^2 <= r^2
// We only count z > 0 here
val maxDz = kotlin.math.floor(kotlin.math.sqrt(remainingSq)).toLong()
if (maxDz > 0) {
quadrantBlocks += maxDz
}
}
// Blocks on axes (including center)
// Center row (z=0): covers x from -maxX to +maxX
// Center col (x=0): covers z from -maxZ to +maxZ (excluding center to avoid double count? No let's do it cleanly)
// Let's count clearer:
// 1. Center block (0,0): 1
// 2. Axes (excluding center):
// Length of radius along axis (excluding center) is simply floor(actualRadius)
// So 4 arms of length floor(actualRadius)
val rFloor = kotlin.math.floor(actualRadius).toLong()
val axisBlocks = 1 + (4 * rFloor)
// Total base area = (Quadrant * 4) + Axes
val baseArea = (quadrantBlocks * 4) + axisBlocks
return baseArea * totalHeight()
}
}
abstract fun contains(x: Double, y: Double, z: Double): Boolean
abstract fun volume(): Long
}
/**

View File

@ -67,6 +67,20 @@ class LandService(private val database: Database) {
return true
}
private val volumeCache = ConcurrentHashMap<Int, Long>()
fun getAsyncVolume(land: Land): java.util.concurrent.CompletableFuture<Long> {
if (volumeCache.containsKey(land.id)) {
return java.util.concurrent.CompletableFuture.completedFuture(volumeCache[land.id])
}
return java.util.concurrent.CompletableFuture.supplyAsync {
val vol = land.calculateExactVolume()
volumeCache[land.id] = vol
vol
}
}
fun modifyLand(id: Int, modifier: (LandData) -> Unit): Boolean {
val land = cache[id] ?: return false
val newParts = ArrayList(land.data.parts)
@ -83,6 +97,9 @@ class LandService(private val database: Database) {
val updatedLand = land.copy(data = newData)
cache[id] = updatedLand
// Invalidate volume cache
volumeCache.remove(id)
// Update spatial index
spatialIndex.updateLand(updatedLand)
return true
@ -97,6 +114,7 @@ class LandService(private val database: Database) {
}
cache.remove(id)
volumeCache.remove(id)
// Remove from spatial index
spatialIndex.removeLand(land)

View File

@ -44,6 +44,12 @@ class VisualizerTask(private val landService: LandService) : Runnable {
}
nearbyLands.forEach { land ->
// Verify that this is the current version of the land
val currentLand = landService.getLand(land.id)
if (currentLand == null || currentLand != land) {
return@forEach
}
// Further distance check could be added here if needed,
// but chunk-based filtering is already efficient enough for visualization logic
land.data.parts.forEach { shape ->