Gjallarhorn: Timelapse Mode

This commit is contained in:
Kenneth Endfinger 2022-01-07 06:15:26 -05:00
parent cc6fbaae83
commit bc2d3e28ae
No known key found for this signature in database
GPG Key ID: C4E68E5647420E10
26 changed files with 251 additions and 163 deletions

View File

@ -1,17 +1,25 @@
# Foundation # Foundation
Foundation is a set of plugins that implement the core functionality for a small community Minecraft Foundation is a set of plugins that implement the core functionality for a small community Minecraft
server. server.
## Plugins ## Plugins
* foundation-core - Core functionality
* foundation-bifrost - Discord chat bridge * foundation-core: Core functionality
* foundation-heimdall - Event tracking * foundation-bifrost: Discord chat bridge
* foundation-heimdall: Event tracking
## Tools
* tool-gjallarhorn - Heimdall swiss army knife
## Installation ## Installation
The following command downloads and runs a script that will fetch the latest update manifest, and The following command downloads and runs a script that will fetch the latest update manifest, and
install all plugins available. It can also be used to update plugins to the latest version install all plugins available. It can also be used to update plugins to the latest version
available. available.
```
```bash
# Always validate the contents of a script from the internet! # Always validate the contents of a script from the internet!
bash -c "$(curl -sL https://git.gorence.io/lgorence/foundation/-/raw/main/install.sh)" bash -c "$(curl -sL https://git.gorence.io/lgorence/foundation/-/raw/main/install.sh)"
``` ```

View File

@ -10,6 +10,9 @@ plugins {
id("com.github.johnrengelman.shadow") version "7.1.1" apply false id("com.github.johnrengelman.shadow") version "7.1.1" apply false
} }
fun Project.isFoundationPlugin() = name.startsWith("foundation-")
fun Project.isFoundationTool() = !isFoundationPlugin()
// Disable the JAR task for the root project. // Disable the JAR task for the root project.
tasks["jar"].enabled = false tasks["jar"].enabled = false
@ -39,7 +42,7 @@ tasks.create("updateManifests") {
writer.use { writer.use {
val rootPath = rootProject.rootDir.toPath() val rootPath = rootProject.rootDir.toPath()
val updateManifest = subprojects.mapNotNull { project -> val updateManifest = subprojects.mapNotNull { project ->
if (project.name == "foundation-gjallarhorn") { if (project.isFoundationTool()) {
return@mapNotNull null return@mapNotNull null
} }
val files = project.tasks.getByName("shadowJar").outputs val files = project.tasks.getByName("shadowJar").outputs
@ -122,7 +125,7 @@ subprojects {
} }
} }
if (project.name != "foundation-gjallarhorn") { if (project.isFoundationTool()) {
tasks.withType<ShadowJar> { tasks.withType<ShadowJar> {
archiveClassifier.set("plugin") archiveClassifier.set("plugin")
} }

View File

@ -1,17 +0,0 @@
package cloud.kubelet.foundation.gjallarhorn
data class BlockOffset(
val x: Long,
val y: Long,
val z: Long
) {
fun apply(position: BlockPosition) = position.copy(
x = position.x + x,
y = position.y + y,
z = position.z + z
)
companion object {
val none = BlockOffset(0, 0, 0)
}
}

View File

@ -1,19 +0,0 @@
package cloud.kubelet.foundation.gjallarhorn
import java.util.*
data class BlockPosition(
val x: Long,
val y: Long,
val z: Long
) {
override fun equals(other: Any?): Boolean {
if (other !is BlockPosition) {
return false
}
return other.x == x && other.y == y && other.z == z
}
override fun hashCode(): Int = Objects.hash(x, y, z)
}

View File

@ -1,81 +0,0 @@
package cloud.kubelet.foundation.gjallarhorn.commands
import cloud.kubelet.foundation.gjallarhorn.*
import cloud.kubelet.foundation.gjallarhorn.util.savePngFile
import cloud.kubelet.foundation.heimdall.view.BlockChangeView
import com.github.ajalt.clikt.core.CliktCommand
import com.github.ajalt.clikt.core.requireObject
import com.github.ajalt.clikt.parameters.options.flag
import com.github.ajalt.clikt.parameters.options.option
import org.jetbrains.exposed.sql.Database
import org.jetbrains.exposed.sql.SqlExpressionBuilder.lessEq
import org.jetbrains.exposed.sql.and
import org.jetbrains.exposed.sql.select
import org.jetbrains.exposed.sql.transactions.transaction
import java.time.Instant
import java.util.concurrent.atomic.AtomicLong
class BlockLogReplay : CliktCommand("Replay Block Logs", name = "replay-block-log") {
private val db by requireObject<Database>()
private val timeAsString by option("--time", help = "Replay Time")
private val renderTopDown by option("--render-top-down", help = "Render TOp Down Image").flag()
private val renderHeightMap by option("--render-height-map", help = "Render Height Map Image").flag()
private val considerAirBlocks by option("--consider-air-blocks", help = "Enable Air Block Consideration").flag()
override fun run() {
val filter = compose(
combine = { a, b -> a and b },
{ timeAsString != null } to { BlockChangeView.time lessEq Instant.parse(timeAsString) }
)
val tracker =
BlockStateTracker(if (considerAirBlocks) BlockTrackMode.AirOnDelete else BlockTrackMode.RemoveOnDelete)
val blockChangeCounter = AtomicLong()
transaction(db) {
BlockChangeView.select(filter).orderBy(BlockChangeView.time).forEach { row ->
val changeIsBreak = row[BlockChangeView.isBreak]
val x = row[BlockChangeView.x]
val y = row[BlockChangeView.y]
val z = row[BlockChangeView.z]
val block = row[BlockChangeView.block]
val location = BlockPosition(x.toLong(), y.toLong(), z.toLong())
if (changeIsBreak) {
tracker.delete(location)
} else {
tracker.place(location, BlockState(block))
}
val count = blockChangeCounter.addAndGet(1)
if (count % 1000L == 0L) {
System.err.println("Calculating Block Changes... $count")
}
}
}
System.err.println("Total Block Changes... ${blockChangeCounter.get()}")
val uniqueBlockPositions = tracker.blocks.size
System.err.println("Unique Block Positions... $uniqueBlockPositions")
val blockZeroOffset = tracker.calculateZeroBlockOffset()
System.err.println("Zero Block Offset... $blockZeroOffset")
if (renderTopDown) {
val image = BlockStateImage()
tracker.populate(image, offset = blockZeroOffset)
val bufferedImage = image.buildTopDownImage()
bufferedImage.savePngFile("top-down.png")
} else if (renderHeightMap) {
val image = BlockStateImage()
tracker.populate(image, offset = blockZeroOffset)
val bufferedImage = image.buildHeightMapImage()
bufferedImage.savePngFile("height-map.png")
} else {
println("x,y,z,block")
for ((position, block) in tracker.blocks) {
println("${position.x},${position.y},${position.z},${block.type}")
}
}
}
}

View File

@ -1,7 +0,0 @@
package cloud.kubelet.foundation.gjallarhorn.util
import java.awt.image.BufferedImage
import java.io.File
import javax.imageio.ImageIO
fun BufferedImage.savePngFile(path: String) = ImageIO.write(this, "png", File(path))

View File

@ -1,7 +1,6 @@
package cloud.kubelet.foundation.heimdall.event package cloud.kubelet.foundation.heimdall.event
import cloud.kubelet.foundation.heimdall.storageBlockName import cloud.kubelet.foundation.heimdall.storageBlockName
import cloud.kubelet.foundation.heimdall.table.BlockBreakTable
import cloud.kubelet.foundation.heimdall.table.BlockPlaceTable import cloud.kubelet.foundation.heimdall.table.BlockPlaceTable
import org.bukkit.Location import org.bukkit.Location
import org.bukkit.Material import org.bukkit.Material
@ -22,15 +21,15 @@ class BlockPlace(
override fun store(transaction: Transaction) { override fun store(transaction: Transaction) {
transaction.apply { transaction.apply {
BlockPlaceTable.insert { BlockPlaceTable.insert {
it[BlockBreakTable.time] = timestamp it[time] = timestamp
it[BlockBreakTable.player] = playerUniqueIdentity it[player] = playerUniqueIdentity
it[BlockBreakTable.world] = location.world.uid it[world] = location.world.uid
it[BlockBreakTable.x] = location.x it[x] = location.x
it[BlockBreakTable.y] = location.y it[y] = location.y
it[BlockBreakTable.z] = location.z it[z] = location.z
it[BlockBreakTable.pitch] = location.pitch.toDouble() it[pitch] = location.pitch.toDouble()
it[BlockBreakTable.yaw] = location.yaw.toDouble() it[yaw] = location.yaw.toDouble()
it[BlockBreakTable.block] = material.storageBlockName it[block] = material.storageBlockName
} }
} }
} }

View File

@ -4,5 +4,5 @@ include(
":foundation-core", ":foundation-core",
":foundation-bifrost", ":foundation-bifrost",
":foundation-heimdall", ":foundation-heimdall",
":foundation-gjallarhorn", ":tool-gjallarhorn",
) )

View File

@ -12,7 +12,7 @@ class GjallarhornCommand : CliktCommand(invokeWithoutSubcommand = true) {
private val jdbcConnectionUsername by option("-u", "--connection-username", help = "JDBC Connection Username") private val jdbcConnectionUsername by option("-u", "--connection-username", help = "JDBC Connection Username")
.default("jdbc:postgresql://localhost/foundation") .default("jdbc:postgresql://localhost/foundation")
private val jdbcConnectionPassword by option("-p", "--connection-password", help = "JDBC Connection Passowrd") private val jdbcConnectionPassword by option("-p", "--connection-password", help = "JDBC Connection Password")
.default("jdbc:postgresql://localhost/foundation") .default("jdbc:postgresql://localhost/foundation")
override fun run() { override fun run() {

View File

@ -0,0 +1,130 @@
package cloud.kubelet.foundation.gjallarhorn.commands
import cloud.kubelet.foundation.gjallarhorn.compose
import cloud.kubelet.foundation.gjallarhorn.render.*
import cloud.kubelet.foundation.gjallarhorn.util.savePngFile
import cloud.kubelet.foundation.heimdall.view.BlockChangeView
import com.github.ajalt.clikt.core.CliktCommand
import com.github.ajalt.clikt.core.requireObject
import com.github.ajalt.clikt.parameters.options.flag
import com.github.ajalt.clikt.parameters.options.option
import com.github.ajalt.clikt.parameters.options.required
import com.github.ajalt.clikt.parameters.types.enum
import jetbrains.exodus.kotlin.notNull
import org.jetbrains.exposed.sql.*
import org.jetbrains.exposed.sql.SqlExpressionBuilder.lessEq
import org.jetbrains.exposed.sql.transactions.transaction
import org.slf4j.LoggerFactory
import java.awt.image.BufferedImage
import java.time.Duration
import java.time.Instant
import java.util.concurrent.atomic.AtomicLong
class BlockLogReplay : CliktCommand("Replay Block Logs", name = "replay-block-log") {
private val db by requireObject<Database>()
private val exactTimeAsString by option("--time", help = "Replay Time")
private val timeLapseMode by option("--timelapse", help = "Timelapse Mode").enum<TimeLapseMode> { it.id }
private val render by option("--render", help = "Render Top Down Image").enum<RenderType> { it.id }.required()
private val considerAirBlocks by option("--consider-air-blocks", help = "Enable Air Block Consideration").flag()
private val logger = LoggerFactory.getLogger(BlockLogReplay::class.java)
override fun run() {
if (timeLapseMode != null) {
val (start, end) = transaction(db) {
val minTimeColumn = BlockChangeView.time.min().notNull
val maxTimeColumn = BlockChangeView.time.max().notNull
val row = BlockChangeView.slice(minTimeColumn, maxTimeColumn).selectAll().single()
row[minTimeColumn]!! to row[maxTimeColumn]!!
}
val intervals = mutableListOf<Instant>()
var current = start
while (!current.isAfter(end)) {
intervals.add(current)
current = current.plus(timeLapseMode!!.interval)
}
val trackers = mutableMapOf<Int, BlockStateTracker>()
for (time in intervals) {
val index = intervals.indexOf(time) + 1
val tracker = buildTrackerState(time, "Timelapse-${index}")
if (tracker.isEmpty()) {
continue
}
trackers[index] = tracker
}
val allBlockOffsets = trackers.map { it.value.calculateZeroBlockOffset() }
val globalBlockOffset = BlockPosition.maxOf(allBlockOffsets.asSequence())
val allBlockMaxes = trackers.map { it.value.calculateZeroBlockOffset() }
val globalBlockMax = BlockPosition.maxOf(allBlockMaxes.asSequence())
val globalBlockExpanse = BlockExpanse.offsetAndMax(globalBlockOffset, globalBlockMax)
for ((i, tracker) in trackers.entries) {
saveRenderImage(tracker, globalBlockExpanse, "-${i}")
}
} else {
val time = if (exactTimeAsString != null) Instant.parse(exactTimeAsString) else null
val tracker = buildTrackerState(time, "Single-Time")
val expanse = BlockExpanse.offsetAndMax(tracker.calculateZeroBlockOffset(), tracker.calculateMaxBlock())
saveRenderImage(tracker, expanse)
}
}
fun saveRenderImage(tracker: BlockStateTracker, expanse: BlockExpanse, suffix: String = "") {
val state = BlockStateImage()
tracker.populateStateImage(state, expanse.offset)
val image = render.renderBufferedImage(state, expanse)
image.savePngFile("${render.id}${suffix}.png")
}
fun buildTrackerState(time: Instant?, job: String): BlockStateTracker {
val filter = compose(
combine = { a, b -> a and b },
{ time != null } to { BlockChangeView.time lessEq time!! }
)
val tracker =
BlockStateTracker(if (considerAirBlocks) BlockTrackMode.AirOnDelete else BlockTrackMode.RemoveOnDelete)
val blockChangeCounter = AtomicLong()
transaction(db) {
BlockChangeView.select(filter).orderBy(BlockChangeView.time).forEach { row ->
val changeIsBreak = row[BlockChangeView.isBreak]
val x = row[BlockChangeView.x]
val y = row[BlockChangeView.y]
val z = row[BlockChangeView.z]
val block = row[BlockChangeView.block]
val location = BlockPosition(x.toLong(), y.toLong(), z.toLong())
if (changeIsBreak) {
tracker.delete(location)
} else {
tracker.place(location, BlockState(block))
}
val count = blockChangeCounter.addAndGet(1)
if (count % 1000L == 0L) {
logger.info("Job $job Calculating Block Changes... $count")
}
}
}
logger.info("Job $job Total Block Changes... ${blockChangeCounter.get()}")
val uniqueBlockPositions = tracker.blocks.size
logger.info("Job $job Unique Block Positions... $uniqueBlockPositions")
return tracker
}
enum class RenderType(val id: String, val renderBufferedImage: (BlockStateImage, BlockExpanse) -> BufferedImage) {
TopDown("top-down", { image, expanse -> image.buildTopDownImage(expanse) }),
HeightMap("height-map", { image, expanse -> image.buildHeightMapImage(expanse) })
}
enum class TimeLapseMode(val id: String, val interval: Duration) {
ByHour("hours", Duration.ofHours(1)),
ByDay("days", Duration.ofDays(1))
}
}

View File

@ -1,5 +1,6 @@
package cloud.kubelet.foundation.gjallarhorn package cloud.kubelet.foundation.gjallarhorn.commands
import cloud.kubelet.foundation.gjallarhorn.compose
import cloud.kubelet.foundation.heimdall.table.PlayerSessionTable import cloud.kubelet.foundation.heimdall.table.PlayerSessionTable
import com.github.ajalt.clikt.core.CliktCommand import com.github.ajalt.clikt.core.CliktCommand
import com.github.ajalt.clikt.core.requireObject import com.github.ajalt.clikt.core.requireObject

View File

@ -2,6 +2,7 @@ package cloud.kubelet.foundation.gjallarhorn
import cloud.kubelet.foundation.gjallarhorn.commands.BlockLogReplay import cloud.kubelet.foundation.gjallarhorn.commands.BlockLogReplay
import cloud.kubelet.foundation.gjallarhorn.commands.PlayerPositionExport import cloud.kubelet.foundation.gjallarhorn.commands.PlayerPositionExport
import cloud.kubelet.foundation.gjallarhorn.commands.PlayerSessionExport
import com.github.ajalt.clikt.core.subcommands import com.github.ajalt.clikt.core.subcommands
fun main(args: Array<String>) = GjallarhornCommand().subcommands( fun main(args: Array<String>) = GjallarhornCommand().subcommands(

View File

@ -0,0 +1,13 @@
package cloud.kubelet.foundation.gjallarhorn.render
class BlockExpanse(
val offset: BlockPosition,
val size: BlockPosition
) {
companion object {
fun offsetAndMax(offset: BlockPosition, max: BlockPosition) = BlockExpanse(
offset,
offset.applyAsOffset(max)
)
}
}

View File

@ -0,0 +1,37 @@
package cloud.kubelet.foundation.gjallarhorn.render
import java.util.*
data class BlockPosition(
val x: Long,
val y: Long,
val z: Long
) {
override fun equals(other: Any?): Boolean {
if (other !is BlockPosition) {
return false
}
return other.x == x && other.y == y && other.z == z
}
override fun hashCode(): Int = Objects.hash(x, y, z)
fun applyAsOffset(position: BlockPosition) = position.copy(
x = position.x + x,
y = position.y + y,
z = position.z + z
)
companion object {
val zero = BlockPosition(0, 0, 0)
fun maxOf(positions: Sequence<BlockPosition>): BlockPosition {
val x = positions.maxOf { it.x }
val y = positions.maxOf { it.y }
val z = positions.maxOf { it.z }
return BlockPosition(x, y, z)
}
}
}

View File

@ -1,4 +1,4 @@
package cloud.kubelet.foundation.gjallarhorn package cloud.kubelet.foundation.gjallarhorn.render
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable

View File

@ -1,4 +1,4 @@
package cloud.kubelet.foundation.gjallarhorn package cloud.kubelet.foundation.gjallarhorn.render
import cloud.kubelet.foundation.gjallarhorn.util.ColorGradient import cloud.kubelet.foundation.gjallarhorn.util.ColorGradient
import cloud.kubelet.foundation.gjallarhorn.util.FloatClamp import cloud.kubelet.foundation.gjallarhorn.util.FloatClamp
@ -18,9 +18,9 @@ class BlockStateImage {
}[position.y] = state }[position.y] = state
} }
fun buildTopDownImage(): BufferedImage { fun buildTopDownImage(expanse: BlockExpanse): BufferedImage {
val colorKey = RandomColorKey() val colorKey = RandomColorKey()
return buildPixelQuadImage { x, z -> return buildPixelQuadImage(expanse) { x, z ->
val maybeYBlocks = blocks[x]?.get(z) val maybeYBlocks = blocks[x]?.get(z)
if (maybeYBlocks == null) { if (maybeYBlocks == null) {
setPixelQuad(x, z, Color.white.rgb) setPixelQuad(x, z, Color.white.rgb)
@ -37,16 +37,16 @@ class BlockStateImage {
} }
} }
fun buildHeightMapImage(): BufferedImage { fun buildHeightMapImage(expanse: BlockExpanse): BufferedImage {
val yMin = blocks.minOf { xSection -> xSection.value.minOf { zSection -> zSection.value.minOf { it.key } } } val yMin = blocks.minOf { xSection -> xSection.value.minOf { zSection -> zSection.value.minOf { it.key } } }
val yMax = blocks.maxOf { xSection -> xSection.value.maxOf { zSection -> zSection.value.maxOf { it.key } } } val yMax = blocks.maxOf { xSection -> xSection.value.maxOf { zSection -> zSection.value.maxOf { it.key } } }
val clamp = FloatClamp(yMin, yMax) val clamp = FloatClamp(yMin, yMax)
return buildHeatMapImage(clamp) { x, z -> blocks[x]?.get(z)?.maxOf { it.key } } return buildHeatMapImage(expanse, clamp) { x, z -> blocks[x]?.get(z)?.maxOf { it.key } }
} }
fun buildHeatMapImage(clamp: FloatClamp, calculate: (Long, Long) -> Long?): BufferedImage = fun buildHeatMapImage(expanse: BlockExpanse, clamp: FloatClamp, calculate: (Long, Long) -> Long?): BufferedImage =
buildPixelQuadImage { x, z -> buildPixelQuadImage(expanse) { x, z ->
val value = calculate(x, z) val value = calculate(x, z)
val color = if (value != null) { val color = if (value != null) {
val floatValue = clamp.convert(value) val floatValue = clamp.convert(value)
@ -65,13 +65,13 @@ class BlockStateImage {
setRGB((x.toInt() * 2) + 1, (z.toInt() * 2) + 1, rgb) setRGB((x.toInt() * 2) + 1, (z.toInt() * 2) + 1, rgb)
} }
private fun buildPixelQuadImage(callback: BufferedImage.(Long, Long) -> Unit): BufferedImage { private fun buildPixelQuadImage(expanse: BlockExpanse, callback: BufferedImage.(Long, Long) -> Unit): BufferedImage {
val xMax = blocks.keys.maxOf { it } val width = expanse.size.x
val zMax = blocks.maxOf { xSection -> xSection.value.maxOf { zSection -> zSection.key } } val height = expanse.size.z
val bufferedImage = BufferedImage(xMax.toInt() * 2, zMax.toInt() * 2, BufferedImage.TYPE_4BYTE_ABGR) val bufferedImage = BufferedImage(width.toInt() * 2, height.toInt() * 2, BufferedImage.TYPE_4BYTE_ABGR)
for (x in 0 until xMax) { for (x in 0 until width) {
for (z in 0 until zMax) { for (z in 0 until height) {
callback(bufferedImage, x, z) callback(bufferedImage, x, z)
} }
} }

View File

@ -1,4 +1,4 @@
package cloud.kubelet.foundation.gjallarhorn package cloud.kubelet.foundation.gjallarhorn.render
import kotlin.math.absoluteValue import kotlin.math.absoluteValue
@ -17,7 +17,7 @@ class BlockStateTracker(private val mode: BlockTrackMode = BlockTrackMode.Remove
} }
} }
fun calculateZeroBlockOffset(): BlockOffset { fun calculateZeroBlockOffset(): BlockPosition {
val x = blocks.keys.minOf { it.x } val x = blocks.keys.minOf { it.x }
val y = blocks.keys.minOf { it.y } val y = blocks.keys.minOf { it.y }
val z = blocks.keys.minOf { it.z } val z = blocks.keys.minOf { it.z }
@ -26,12 +26,21 @@ class BlockStateTracker(private val mode: BlockTrackMode = BlockTrackMode.Remove
val yOffset = if (y < 0) y.absoluteValue else 0 val yOffset = if (y < 0) y.absoluteValue else 0
val zOffset = if (z < 0) z.absoluteValue else 0 val zOffset = if (z < 0) z.absoluteValue else 0
return BlockOffset(xOffset, yOffset, zOffset) return BlockPosition(xOffset, yOffset, zOffset)
} }
fun populate(image: BlockStateImage, offset: BlockOffset = BlockOffset.none) { fun calculateMaxBlock(): BlockPosition {
val x = blocks.keys.maxOf { it.x }
val y = blocks.keys.maxOf { it.y }
val z = blocks.keys.maxOf { it.z }
return BlockPosition(x, y, z)
}
fun isEmpty() = blocks.isEmpty()
fun populateStateImage(image: BlockStateImage, offset: BlockPosition = BlockPosition.zero) {
blocks.forEach { (position, state) -> blocks.forEach { (position, state) ->
val realPosition = offset.apply(position) val realPosition = offset.applyAsOffset(position)
image.put(realPosition, state) image.put(realPosition, state)
} }
} }

View File

@ -1,4 +1,4 @@
package cloud.kubelet.foundation.gjallarhorn package cloud.kubelet.foundation.gjallarhorn.render
enum class BlockTrackMode { enum class BlockTrackMode {
RemoveOnDelete, RemoveOnDelete,

View File

@ -0,0 +1,11 @@
package cloud.kubelet.foundation.gjallarhorn.util
import java.awt.image.BufferedImage
import java.io.File
import javax.imageio.ImageIO
fun BufferedImage.savePngFile(path: String) {
if (!ImageIO.write(this, "png", File(path))) {
throw RuntimeException("Unable to write PNG.")
}
}