Gjallarhorn: Timelapse Mode

This commit is contained in:
Kenneth Endfinger
2022-01-07 06:15:26 -05:00
parent cc6fbaae83
commit bc2d3e28ae
26 changed files with 251 additions and 163 deletions

View File

@ -0,0 +1,10 @@
dependencies {
implementation(project(":foundation-core"))
implementation(project(":foundation-heimdall"))
implementation("org.slf4j:slf4j-simple:1.7.32")
implementation("com.github.ajalt.clikt:clikt:3.3.0")
}
listOf(tasks.jar, tasks.shadowJar).map { it.get() }.forEach { task ->
task.manifest.attributes["Main-Class"] = "cloud.kubelet.foundation.gjallarhorn.MainKt"
}

View File

@ -0,0 +1,22 @@
package cloud.kubelet.foundation.gjallarhorn
import com.github.ajalt.clikt.core.CliktCommand
import com.github.ajalt.clikt.parameters.options.default
import com.github.ajalt.clikt.parameters.options.option
import org.jetbrains.exposed.sql.Database
class GjallarhornCommand : CliktCommand(invokeWithoutSubcommand = true) {
private val jdbcConnectionUrl by option("-c", "--connection-url", help = "JDBC Connection URL")
.default("jdbc:postgresql://localhost/foundation")
private val jdbcConnectionUsername by option("-u", "--connection-username", help = "JDBC Connection Username")
.default("jdbc:postgresql://localhost/foundation")
private val jdbcConnectionPassword by option("-p", "--connection-password", help = "JDBC Connection Password")
.default("jdbc:postgresql://localhost/foundation")
override fun run() {
val db = Database.connect(jdbcConnectionUrl, user = jdbcConnectionUsername, password = jdbcConnectionPassword)
currentContext.findOrSetObject { db }
}
}

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

@ -0,0 +1,49 @@
package cloud.kubelet.foundation.gjallarhorn.commands
import cloud.kubelet.foundation.gjallarhorn.compose
import cloud.kubelet.foundation.heimdall.table.PlayerPositionTable
import com.github.ajalt.clikt.core.CliktCommand
import com.github.ajalt.clikt.core.requireObject
import com.github.ajalt.clikt.parameters.options.option
import org.jetbrains.exposed.sql.Database
import org.jetbrains.exposed.sql.SqlExpressionBuilder.eq
import org.jetbrains.exposed.sql.SqlExpressionBuilder.greaterEq
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.*
class PlayerPositionExport : CliktCommand(name = "export-player-positions", help = "Export Player Positions") {
private val db by requireObject<Database>()
private val playerIdString by option("--player", help = "Player ID")
private val startTimeString by option("--start-time", help = "Start Time")
private val endTimeString by option("--end-time", help = "End Time")
override fun run() {
val filter = compose(
combine = { a, b -> a and b },
{ startTimeString != null } to { PlayerPositionTable.time greaterEq Instant.parse(startTimeString) },
{ endTimeString != null } to { PlayerPositionTable.time lessEq Instant.parse(endTimeString) },
{ playerIdString != null } to { PlayerPositionTable.player eq UUID.fromString(playerIdString) }
)
println("time,player,world,x,y,z,pitch,yaw")
transaction(db) {
PlayerPositionTable.select(filter).orderBy(PlayerPositionTable.time).forEach { row ->
val time = row[PlayerPositionTable.time]
val player = row[PlayerPositionTable.player]
val world = row[PlayerPositionTable.world]
val x = row[PlayerPositionTable.x]
val y = row[PlayerPositionTable.y]
val z = row[PlayerPositionTable.z]
val pitch = row[PlayerPositionTable.pitch]
val yaw = row[PlayerPositionTable.yaw]
println("${time},${player},${world},${x},${y},${z},${pitch},${yaw}")
}
}
}
}

View File

@ -0,0 +1,41 @@
package cloud.kubelet.foundation.gjallarhorn.commands
import cloud.kubelet.foundation.gjallarhorn.compose
import cloud.kubelet.foundation.heimdall.table.PlayerSessionTable
import com.github.ajalt.clikt.core.CliktCommand
import com.github.ajalt.clikt.core.requireObject
import com.github.ajalt.clikt.parameters.options.option
import org.jetbrains.exposed.sql.Database
import org.jetbrains.exposed.sql.SqlExpressionBuilder.eq
import org.jetbrains.exposed.sql.and
import org.jetbrains.exposed.sql.select
import org.jetbrains.exposed.sql.transactions.transaction
import java.util.*
class PlayerSessionExport : CliktCommand(name = "export-player-sessions", help = "Export Player Sessions") {
private val db by requireObject<Database>()
private val playerIdString by option("--player-id", help = "Player ID")
private val playerNameString by option("--player-name", help = "Player Name")
override fun run() {
val filter = compose(
combine = { a, b -> a and b },
{ playerIdString != null } to { PlayerSessionTable.player eq UUID.fromString(playerIdString) },
{ playerNameString != null } to { PlayerSessionTable.name eq playerNameString!! }
)
println("id,player,name,start,end")
transaction(db) {
PlayerSessionTable.select(filter).orderBy(PlayerSessionTable.endTime).forEach { row ->
val id = row[PlayerSessionTable.id]
val player = row[PlayerSessionTable.player]
val name = row[PlayerSessionTable.name]
val start = row[PlayerSessionTable.startTime]
val end = row[PlayerSessionTable.endTime]
println("${id},${player},${name},${start},${end}")
}
}
}
}

View File

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

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

@ -0,0 +1,6 @@
package cloud.kubelet.foundation.gjallarhorn.render
import kotlinx.serialization.Serializable
@Serializable
data class BlockState(val type: String)

View File

@ -0,0 +1,80 @@
package cloud.kubelet.foundation.gjallarhorn.render
import cloud.kubelet.foundation.gjallarhorn.util.ColorGradient
import cloud.kubelet.foundation.gjallarhorn.util.FloatClamp
import cloud.kubelet.foundation.gjallarhorn.util.RandomColorKey
import java.awt.Color
import java.awt.image.BufferedImage
import java.util.*
class BlockStateImage {
private val blocks = TreeMap<Long, TreeMap<Long, TreeMap<Long, BlockState>>>()
fun put(position: BlockPosition, state: BlockState) {
blocks.getOrPut(position.x) {
TreeMap()
}.getOrPut(position.z) {
TreeMap()
}[position.y] = state
}
fun buildTopDownImage(expanse: BlockExpanse): BufferedImage {
val colorKey = RandomColorKey()
return buildPixelQuadImage(expanse) { x, z ->
val maybeYBlocks = blocks[x]?.get(z)
if (maybeYBlocks == null) {
setPixelQuad(x, z, Color.white.rgb)
return@buildPixelQuadImage
}
val maxBlockState = maybeYBlocks.maxByOrNull { it.key }?.value
if (maxBlockState == null) {
setPixelQuad(x, z, Color.white.rgb)
return@buildPixelQuadImage
}
val color = colorKey.map(maxBlockState.type)
setPixelQuad(x, z, color.rgb)
}
}
fun buildHeightMapImage(expanse: BlockExpanse): BufferedImage {
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 clamp = FloatClamp(yMin, yMax)
return buildHeatMapImage(expanse, clamp) { x, z -> blocks[x]?.get(z)?.maxOf { it.key } }
}
fun buildHeatMapImage(expanse: BlockExpanse, clamp: FloatClamp, calculate: (Long, Long) -> Long?): BufferedImage =
buildPixelQuadImage(expanse) { x, z ->
val value = calculate(x, z)
val color = if (value != null) {
val floatValue = clamp.convert(value)
ColorGradient.HeatMap.getColorAtValue(floatValue)
} else {
Color.white
}
setPixelQuad(x, z, color.rgb)
}
private fun BufferedImage.setPixelQuad(x: Long, z: Long, rgb: Int) {
setRGB(x.toInt() * 2, z.toInt() * 2, rgb)
setRGB((x.toInt() * 2) + 1, z.toInt() * 2, rgb)
setRGB(x.toInt() * 2, (z.toInt() * 2) + 1, rgb)
setRGB((x.toInt() * 2) + 1, (z.toInt() * 2) + 1, rgb)
}
private fun buildPixelQuadImage(expanse: BlockExpanse, callback: BufferedImage.(Long, Long) -> Unit): BufferedImage {
val width = expanse.size.x
val height = expanse.size.z
val bufferedImage = BufferedImage(width.toInt() * 2, height.toInt() * 2, BufferedImage.TYPE_4BYTE_ABGR)
for (x in 0 until width) {
for (z in 0 until height) {
callback(bufferedImage, x, z)
}
}
return bufferedImage
}
}

View File

@ -0,0 +1,47 @@
package cloud.kubelet.foundation.gjallarhorn.render
import kotlin.math.absoluteValue
class BlockStateTracker(private val mode: BlockTrackMode = BlockTrackMode.RemoveOnDelete) {
val blocks = HashMap<BlockPosition, BlockState>()
fun place(position: BlockPosition, state: BlockState) {
blocks[position] = state
}
fun delete(position: BlockPosition) {
if (mode == BlockTrackMode.AirOnDelete) {
blocks[position] = BlockState("minecraft:air")
} else {
blocks.remove(position)
}
}
fun calculateZeroBlockOffset(): BlockPosition {
val x = blocks.keys.minOf { it.x }
val y = blocks.keys.minOf { it.y }
val z = blocks.keys.minOf { it.z }
val xOffset = if (x < 0) x.absoluteValue else 0
val yOffset = if (y < 0) y.absoluteValue else 0
val zOffset = if (z < 0) z.absoluteValue else 0
return BlockPosition(xOffset, yOffset, zOffset)
}
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) ->
val realPosition = offset.applyAsOffset(position)
image.put(realPosition, state)
}
}
}

View File

@ -0,0 +1,6 @@
package cloud.kubelet.foundation.gjallarhorn.render
enum class BlockTrackMode {
RemoveOnDelete,
AirOnDelete
}

View File

@ -0,0 +1,65 @@
package cloud.kubelet.foundation.gjallarhorn.util
import java.awt.Color
import kotlin.math.max
class ColorGradient {
data class ColorPoint(
val r: Float,
val g: Float,
val b: Float,
val value: Float
) {
fun toColor() = Color(
FloatClamp.ColorRgbComponent.convert(r).toInt(),
FloatClamp.ColorRgbComponent.convert(g).toInt(),
FloatClamp.ColorRgbComponent.convert(b).toInt()
)
}
private val points = mutableListOf<ColorPoint>()
fun addColorPoint(red: Float, green: Float, blue: Float, value: Float) {
val point = ColorPoint(red, green, blue, value)
for (x in 0 until points.size) {
if (value < points[x].value) {
points.add(x, point)
return
}
}
points.add(point)
}
fun getColorAtValue(value: Float): Color {
if (points.isEmpty()) {
return ColorPoint(0f, 0f, 0f, value).toColor()
}
for (x in 0 until points.size) {
val current = points[x]
if (value < current.value) {
val previous = points[max(0, x - 1)]
val diff = previous.value - current.value
val fractionBetween = if (diff == 0f) 0f else (value - current.value) / diff
return ColorPoint(
(previous.r - current.r) * fractionBetween + current.r,
(previous.g - current.g) * fractionBetween + current.g,
(previous.b - current.b) * fractionBetween + current.b,
value
).toColor()
}
}
return points.last().copy(value = value).toColor()
}
companion object {
val HeatMap = ColorGradient().apply {
addColorPoint(0f, 0f, 1f, 0.0f)
addColorPoint(0f, 1f, 1f, 0.25f)
addColorPoint(0f, 1f, 0f, 0.5f)
addColorPoint(1f, 1f, 0f, 0.75f)
addColorPoint(1f, 0f, 0f, 1.0f)
}
}
}

View File

@ -0,0 +1,12 @@
package cloud.kubelet.foundation.gjallarhorn.util
import kotlin.math.roundToLong
class FloatClamp(val min: Long, val max: Long) {
fun convert(value: Float): Long = (value * max.toFloat()).roundToLong() + min
fun convert(value: Long): Float = (value - min.toFloat()) / max
companion object {
val ColorRgbComponent = FloatClamp(0, 255)
}
}

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.")
}
}

View File

@ -0,0 +1,19 @@
package cloud.kubelet.foundation.gjallarhorn.util
import java.awt.Color
class RandomColorKey {
private val colors = mutableMapOf<String, Color>()
fun map(key: String) = colors.getOrPut(key) { findUniqueColor() }
private fun findUniqueColor(): Color {
var random = randomColor()
while (colors.values.any { it.rgb == random.rgb }) {
random = randomColor()
}
return random
}
private fun randomColor() = Color((Math.random() * 0x1000000).toInt())
}

View File

@ -0,0 +1,11 @@
package cloud.kubelet.foundation.gjallarhorn
import org.jetbrains.exposed.sql.Op
fun compose(
combine: (Op<Boolean>, Op<Boolean>) -> Op<Boolean>,
vararg filters: Pair<() -> Boolean, () -> Op<Boolean>>
): Op<Boolean> = filters.toMap().entries
.filter { it.key() }
.map { it.value() }
.fold(Op.TRUE as Op<Boolean>, combine)

View File

@ -0,0 +1,64 @@
WITH
unique_player_ids AS (
SELECT
DISTINCT player
FROM heimdall.player_sessions
),
player_names AS (
SELECT
player,
(
SELECT name
FROM heimdall.player_sessions
WHERE player = unique_player_ids.player
ORDER BY "end" DESC
LIMIT 1
) AS name
FROM unique_player_ids
),
unique_world_ids AS (
SELECT
DISTINCT to_world AS world
FROM heimdall.world_changes
),
world_names AS (
SELECT
world,
(
SELECT to_world_name
FROM heimdall.world_changes
WHERE world = heimdall.world_changes.to_world
ORDER BY time DESC
LIMIT 1
) AS name
FROM unique_world_ids
),
player_calculated_positions AS (
SELECT
player,
world,
AVG(x) AS avg_x,
AVG(y) AS avg_y,
AVG(z) AS avg_z,
MAX(x) AS max_x,
MAX(y) AS max_y,
MAX(z) AS max_z,
MIN(x) AS min_x,
MIN(y) AS min_y,
MIN(z) AS min_z,
COUNT(*) AS count,
MODE() WITHIN GROUP (ORDER BY x) AS mode_x,
MODE() WITHIN GROUP (ORDER BY y) AS mode_y,
MODE() WITHIN GROUP (ORDER BY z) AS mode_z
FROM heimdall.player_positions
GROUP BY player, world
)
SELECT
player_names.name AS player_name,
world_names.name AS world_name,
player_calculated_positions.*
FROM player_calculated_positions
JOIN player_names
ON player_names.player = player_calculated_positions.player
JOIN world_names
ON world_names.world = player_calculated_positions.world