Heimdall: It's back!

This commit is contained in:
2023-01-28 19:35:10 -08:00
parent 086f7dba10
commit 7289e5cb9f
87 changed files with 2617 additions and 2 deletions

View File

@ -0,0 +1,7 @@
plugins {
id("gay.pizza.foundation.concrete-plugin")
}
dependencies {
api(project(":common-heimdall"))
}

View File

@ -0,0 +1,21 @@
package gay.pizza.foundation.heimdall.plugin
fun String.sqlSplitStatements(): List<String> {
val statements = mutableListOf<String>()
val buffer = StringBuilder()
fun flush() {
val trimmed = buffer.toString().trim()
if (trimmed.isNotEmpty()) {
statements.add(trimmed)
}
}
for (line in lines()) {
if (line.trim() == "--") {
flush()
} else {
buffer.append(line).append("\n")
}
}
flush()
return statements
}

View File

@ -0,0 +1,201 @@
package gay.pizza.foundation.heimdall.plugin
import gay.pizza.foundation.heimdall.plugin.buffer.BufferFlushThread
import gay.pizza.foundation.heimdall.plugin.buffer.EventBuffer
import gay.pizza.foundation.heimdall.plugin.event.*
import gay.pizza.foundation.heimdall.plugin.model.HeimdallConfig
import gay.pizza.foundation.heimdall.plugin.export.ExportChunksCommand
import com.charleskorn.kaml.Yaml
import com.zaxxer.hikari.HikariConfig
import com.zaxxer.hikari.HikariDataSource
import net.kyori.adventure.text.serializer.legacy.LegacyComponentSerializer
import org.bukkit.event.EventHandler
import org.bukkit.event.Listener
import org.bukkit.event.block.BlockBreakEvent
import org.bukkit.event.block.BlockPlaceEvent
import org.bukkit.event.entity.EntityDeathEvent
import org.bukkit.event.entity.PlayerDeathEvent
import org.bukkit.event.player.*
import org.bukkit.plugin.java.JavaPlugin
import org.jetbrains.exposed.sql.Database
import org.postgresql.Driver
import org.slf4j.Logger
import java.nio.file.Path
import java.time.Duration
import java.time.Instant
import java.util.*
import java.util.concurrent.ConcurrentHashMap
import kotlin.io.path.inputStream
class HeimdallPlugin : JavaPlugin(), Listener {
private lateinit var config: HeimdallConfig
private lateinit var pool: HikariDataSource
internal var db: Database? = null
private val buffer = EventBuffer()
private val bufferFlushThread = BufferFlushThread(this, buffer)
private val playerJoinTimes = ConcurrentHashMap<UUID, Instant>()
private val legacyComponentSerializer = LegacyComponentSerializer.builder().build()
override fun onEnable() {
val exportChunksCommand = getCommand("export_all_chunks") ?: throw Exception("Failed to get export_all_chunks command")
exportChunksCommand.setExecutor(ExportChunksCommand(this))
val pluginDataPath = dataFolder.toPath()
pluginDataPath.toFile().mkdir()
val configPath = copyDefaultConfig<HeimdallPlugin>(
slF4JLogger,
pluginDataPath,
"heimdall.yaml"
)
config = Yaml.default.decodeFromStream(HeimdallConfig.serializer(), configPath.inputStream())
if (!config.enabled) {
slF4JLogger.info("Heimdall is not enabled.")
return
}
slF4JLogger.info("Heimdall is enabled.")
if (!Driver.isRegistered()) {
Driver.register()
}
pool = HikariDataSource(HikariConfig().apply {
jdbcUrl = config.db.url
username = config.db.username
password = config.db.password
maximumPoolSize = 10
idleTimeout = Duration.ofMinutes(5).toMillis()
maxLifetime = Duration.ofMinutes(10).toMillis()
})
val initMigrationContent = HeimdallPlugin::class.java.getResourceAsStream(
"/init.sql"
)?.readAllBytes()?.decodeToString() ?: throw RuntimeException("Unable to find Heimdall init.sql")
val statements = initMigrationContent.sqlSplitStatements()
pool.connection.use { conn ->
conn.autoCommit = false
try {
for (statementAsString in statements) {
conn.prepareStatement(statementAsString).use {
it.execute()
}
}
conn.commit()
} catch (e: Exception) {
conn.rollback()
throw e
} finally {
conn.autoCommit = true
}
}
db = Database.connect(pool)
server.pluginManager.registerEvents(this, this)
bufferFlushThread.start()
}
@EventHandler
fun onPlayerMove(event: PlayerMoveEvent) = buffer.push(PlayerPosition(event))
@EventHandler
fun onBlockBroken(event: BlockPlaceEvent) = buffer.push(BlockPlace(event))
@EventHandler
fun onBlockBroken(event: BlockBreakEvent) = buffer.push(BlockBreak(event))
@EventHandler
fun onPlayerJoin(event: PlayerJoinEvent) {
playerJoinTimes[event.player.uniqueId] = Instant.now()
}
@EventHandler
fun onPlayerQuit(event: PlayerQuitEvent) {
val startTime = playerJoinTimes.remove(event.player.uniqueId) ?: return
val endTime = Instant.now()
buffer.push(PlayerSession(event.player.uniqueId, event.player.name, startTime, endTime))
}
@EventHandler
fun onPlayerDeath(event: PlayerDeathEvent) {
val deathMessage = event.deathMessage()
val deathMessageString = if (deathMessage != null) {
legacyComponentSerializer.serialize(deathMessage)
} else {
null
}
buffer.push(PlayerDeath(event, deathMessageString))
}
@EventHandler
fun onPlayerAdvancementDone(event: PlayerAdvancementDoneEvent) = buffer.push(PlayerAdvancement(event))
@EventHandler
fun onWorldLoad(event: PlayerChangedWorldEvent) = buffer.push(
WorldChange(
event.player.uniqueId,
event.from.uid,
event.from.name,
event.player.world.uid,
event.player.world.name
)
)
@EventHandler
fun onEntityDeath(event: EntityDeathEvent) {
val killer = event.entity.killer ?: return
buffer.push(
EntityKill(
killer.uniqueId,
killer.location,
event.entity.uniqueId,
event.entityType.key.toString()
)
)
}
override fun onDisable() {
bufferFlushThread.stop()
val endTime = Instant.now()
for (playerId in playerJoinTimes.keys().toList()) {
val startTime = playerJoinTimes.remove(playerId) ?: continue
buffer.push(PlayerSession(
playerId,
server.getPlayer(playerId)?.name ?: "__unknown__",
startTime,
endTime
))
}
bufferFlushThread.flush()
}
private inline fun <reified T> copyDefaultConfig(log: Logger, targetPath: Path, resourceName: String): Path {
if (resourceName.startsWith("/")) {
throw IllegalArgumentException("resourceName starts with slash")
}
if (!targetPath.toFile().exists()) {
throw Exception("Configuration output path does not exist!")
}
val outPath = targetPath.resolve(resourceName)
val outFile = outPath.toFile()
if (outFile.exists()) {
log.debug("Configuration file already exists.")
return outPath
}
val resourceStream = T::class.java.getResourceAsStream("/$resourceName")
?: throw Exception("Configuration resource does not exist!")
val outputStream = outFile.outputStream()
resourceStream.use {
outputStream.use {
log.info("Copied default configuration to $outPath")
resourceStream.copyTo(outputStream)
}
}
return outPath
}
}

View File

@ -0,0 +1,49 @@
package gay.pizza.foundation.heimdall.plugin.buffer
import gay.pizza.foundation.heimdall.plugin.HeimdallPlugin
import org.jetbrains.exposed.sql.transactions.transaction
import java.util.concurrent.atomic.AtomicBoolean
class BufferFlushThread(val plugin: HeimdallPlugin, val buffer: EventBuffer) {
private val running = AtomicBoolean(false)
private var thread: Thread? = null
fun start() {
running.set(true)
val thread = Thread {
plugin.slF4JLogger.info("Buffer Flusher Started")
while (running.get()) {
flush()
Thread.sleep(5000)
}
plugin.slF4JLogger.info("Buffer Flusher Stopped")
}
thread.name = "Heimdall Buffer Flush"
thread.isDaemon = false
thread.start()
this.thread = thread
}
fun stop() {
running.set(false)
thread?.join()
}
fun flush() {
try {
val db = plugin.db
if (db == null) {
buffer.clear()
return
}
transaction(plugin.db) {
val count = buffer.flush(this)
if (count > 0) {
plugin.slF4JLogger.debug("Flushed $count Events")
}
}
} catch (e: Exception) {
plugin.slF4JLogger.warn("Failed to flush buffer.", e)
}
}
}

View File

@ -0,0 +1,28 @@
package gay.pizza.foundation.heimdall.plugin.buffer
import gay.pizza.foundation.heimdall.plugin.event.HeimdallEvent
import org.jetbrains.exposed.sql.Transaction
class EventBuffer {
private var events = mutableListOf<HeimdallEvent>()
fun flush(transaction: Transaction): Long {
val referenceOfEvents = events
this.events = mutableListOf()
var count = 0L
while (referenceOfEvents.isNotEmpty()) {
val event = referenceOfEvents.removeAt(0)
event.store(transaction)
count++
}
return count
}
fun push(event: HeimdallEvent) {
events.add(event)
}
fun clear() {
events = mutableListOf()
}
}

View File

@ -0,0 +1,35 @@
package gay.pizza.foundation.heimdall.plugin.event
import gay.pizza.foundation.heimdall.table.BlockBreakTable
import org.bukkit.Location
import org.bukkit.Material
import org.bukkit.event.block.BlockBreakEvent
import org.jetbrains.exposed.sql.Transaction
import org.jetbrains.exposed.sql.insert
import java.time.Instant
import java.util.*
class BlockBreak(
val playerUniqueIdentity: UUID,
val location: Location,
val material: Material,
val timestamp: Instant = Instant.now()
) : HeimdallEvent() {
constructor(event: BlockBreakEvent) : this(event.player.uniqueId, event.block.location, event.block.type)
override fun store(transaction: Transaction) {
transaction.apply {
BlockBreakTable.insert {
it[time] = timestamp
it[player] = playerUniqueIdentity
it[world] = location.world.uid
it[x] = location.x
it[y] = location.y
it[z] = location.z
it[pitch] = location.pitch.toDouble()
it[yaw] = location.yaw.toDouble()
it[block] = material.key.toString()
}
}
}
}

View File

@ -0,0 +1,35 @@
package gay.pizza.foundation.heimdall.plugin.event
import gay.pizza.foundation.heimdall.table.BlockPlaceTable
import org.bukkit.Location
import org.bukkit.Material
import org.bukkit.event.block.BlockPlaceEvent
import org.jetbrains.exposed.sql.Transaction
import org.jetbrains.exposed.sql.insert
import java.time.Instant
import java.util.*
class BlockPlace(
val playerUniqueIdentity: UUID,
val location: Location,
val material: Material,
val timestamp: Instant = Instant.now()
) : HeimdallEvent() {
constructor(event: BlockPlaceEvent) : this(event.player.uniqueId, event.block.location, event.block.type)
override fun store(transaction: Transaction) {
transaction.apply {
BlockPlaceTable.insert {
it[time] = timestamp
it[player] = playerUniqueIdentity
it[world] = location.world.uid
it[x] = location.x
it[y] = location.y
it[z] = location.z
it[pitch] = location.pitch.toDouble()
it[yaw] = location.yaw.toDouble()
it[block] = material.key.toString()
}
}
}
}

View File

@ -0,0 +1,33 @@
package gay.pizza.foundation.heimdall.plugin.event
import gay.pizza.foundation.heimdall.table.EntityKillTable
import org.bukkit.Location
import org.jetbrains.exposed.sql.Transaction
import org.jetbrains.exposed.sql.insert
import java.time.Instant
import java.util.*
class EntityKill(
val playerUniqueIdentity: UUID,
val location: Location,
val entityUniqueIdentity: UUID,
val entityTypeName: String,
val timestamp: Instant = Instant.now()
) : HeimdallEvent() {
override fun store(transaction: Transaction) {
transaction.apply {
EntityKillTable.insert {
it[time] = timestamp
it[player] = playerUniqueIdentity
it[world] = location.world.uid
it[x] = location.x
it[y] = location.y
it[z] = location.z
it[pitch] = location.pitch.toDouble()
it[yaw] = location.yaw.toDouble()
it[entity] = entityUniqueIdentity
it[entityType] = entityTypeName
}
}
}
}

View File

@ -0,0 +1,7 @@
package gay.pizza.foundation.heimdall.plugin.event
import org.jetbrains.exposed.sql.Transaction
abstract class HeimdallEvent {
abstract fun store(transaction: Transaction)
}

View File

@ -0,0 +1,35 @@
package gay.pizza.foundation.heimdall.plugin.event
import gay.pizza.foundation.heimdall.table.PlayerAdvancementTable
import org.bukkit.Location
import org.bukkit.advancement.Advancement
import org.bukkit.event.player.PlayerAdvancementDoneEvent
import org.jetbrains.exposed.sql.Transaction
import org.jetbrains.exposed.sql.insert
import java.time.Instant
import java.util.*
class PlayerAdvancement(
val playerUniqueIdentity: UUID,
val location: Location,
val advancement: Advancement,
val timestamp: Instant = Instant.now()
) : HeimdallEvent() {
constructor(event: PlayerAdvancementDoneEvent) : this(event.player.uniqueId, event.player.location, event.advancement)
override fun store(transaction: Transaction) {
transaction.apply {
PlayerAdvancementTable.insert {
it[time] = timestamp
it[player] = playerUniqueIdentity
it[world] = location.world.uid
it[x] = location.x
it[y] = location.y
it[z] = location.z
it[pitch] = location.pitch.toDouble()
it[yaw] = location.yaw.toDouble()
it[advancement] = this@PlayerAdvancement.advancement.key.toString()
}
}
}
}

View File

@ -0,0 +1,41 @@
package gay.pizza.foundation.heimdall.plugin.event
import gay.pizza.foundation.heimdall.table.PlayerDeathTable
import org.bukkit.Location
import org.bukkit.event.entity.PlayerDeathEvent
import org.jetbrains.exposed.sql.Transaction
import org.jetbrains.exposed.sql.insert
import java.time.Instant
import java.util.*
class PlayerDeath(
val playerUniqueIdentity: UUID,
val location: Location,
val experienceLevel: Float,
val deathMessage: String?,
val timestamp: Instant = Instant.now()
) : HeimdallEvent() {
constructor(event: PlayerDeathEvent, deathMessage: String? = null) : this(
event.player.uniqueId,
event.player.location,
event.player.exp,
deathMessage
)
override fun store(transaction: Transaction) {
transaction.apply {
PlayerDeathTable.insert {
it[time] = timestamp
it[player] = playerUniqueIdentity
it[world] = location.world.uid
it[x] = location.x
it[y] = location.y
it[z] = location.z
it[pitch] = location.pitch.toDouble()
it[yaw] = location.yaw.toDouble()
it[experience] = experienceLevel.toDouble()
it[message] = deathMessage
}
}
}
}

View File

@ -0,0 +1,32 @@
package gay.pizza.foundation.heimdall.plugin.event
import gay.pizza.foundation.heimdall.table.PlayerPositionTable
import org.bukkit.Location
import org.bukkit.event.player.PlayerMoveEvent
import org.jetbrains.exposed.sql.Transaction
import org.jetbrains.exposed.sql.insert
import java.time.Instant
import java.util.*
class PlayerPosition(
val playerUniqueIdentity: UUID,
val location: Location,
val timestamp: Instant = Instant.now()
) : HeimdallEvent() {
constructor(event: PlayerMoveEvent) : this(event.player.uniqueId, event.to)
override fun store(transaction: Transaction) {
transaction.apply {
PlayerPositionTable.insert {
it[time] = timestamp
it[player] = playerUniqueIdentity
it[world] = location.world.uid
it[x] = location.x
it[y] = location.y
it[z] = location.z
it[pitch] = location.pitch.toDouble()
it[yaw] = location.yaw.toDouble()
}
}
}
}

View File

@ -0,0 +1,26 @@
package gay.pizza.foundation.heimdall.plugin.event
import gay.pizza.foundation.heimdall.table.PlayerSessionTable
import org.jetbrains.exposed.sql.Transaction
import org.jetbrains.exposed.sql.insert
import java.time.Instant
import java.util.*
class PlayerSession(
val playerUniqueIdentity: UUID,
val playerName: String,
val startTimeInstant: Instant,
val endTimeInstant: Instant
) : HeimdallEvent() {
override fun store(transaction: Transaction) {
transaction.apply {
PlayerSessionTable.insert {
it[id] = UUID.randomUUID()
it[player] = playerUniqueIdentity
it[name] = playerName
it[startTime] = startTimeInstant
it[endTime] = endTimeInstant
}
}
}
}

View File

@ -0,0 +1,29 @@
package gay.pizza.foundation.heimdall.plugin.event
import gay.pizza.foundation.heimdall.table.WorldChangeTable
import org.jetbrains.exposed.sql.Transaction
import org.jetbrains.exposed.sql.insert
import java.time.Instant
import java.util.*
class WorldChange(
val playerUniqueIdentity: UUID,
val fromWorldId: UUID,
val fromWorldActualName: String,
val toWorldId: UUID,
val toWorldActualName: String,
val timestamp: Instant = Instant.now()
) : HeimdallEvent() {
override fun store(transaction: Transaction) {
transaction.apply {
WorldChangeTable.insert {
it[time] = timestamp
it[player] = playerUniqueIdentity
it[fromWorld] = fromWorldId
it[fromWorldName] = fromWorldActualName
it[toWorld] = toWorldId
it[toWorldName] = toWorldActualName
}
}
}
}

View File

@ -0,0 +1,70 @@
package gay.pizza.foundation.heimdall.plugin.export
import gay.pizza.foundation.heimdall.export.ExportedBlock
import gay.pizza.foundation.heimdall.export.ExportedChunk
import gay.pizza.foundation.heimdall.export.ExportedChunkSection
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.encodeToStream
import org.bukkit.Chunk
import org.bukkit.ChunkSnapshot
import org.bukkit.World
import org.bukkit.plugin.Plugin
import java.io.File
import java.util.zip.GZIPOutputStream
class ChunkExporter(private val plugin: Plugin, val world: World) {
private val json = Json {
ignoreUnknownKeys = true
}
fun exportLoadedChunksAsync() {
exportChunkListAsync(world.loadedChunks.toList())
}
private fun exportChunkListAsync(chunks: List<Chunk>) {
plugin.slF4JLogger.info("Exporting ${chunks.size} Chunks")
val snapshots = chunks.map { it.chunkSnapshot }
Thread {
for (snapshot in snapshots) {
exportChunkSnapshot(snapshot)
}
plugin.slF4JLogger.info("Exported ${chunks.size} Chunks")
}.start()
}
private fun exportChunkSnapshot(snapshot: ChunkSnapshot) {
val sections = mutableListOf<ExportedChunkSection>()
val yRange = world.minHeight until world.maxHeight
val chunkRange = 0..15
for (x in chunkRange) {
for (z in chunkRange) {
sections.add(exportChunkSection(snapshot, yRange, x, z))
}
}
val exported = ExportedChunk(snapshot.x, snapshot.z, sections)
saveChunkSnapshot(snapshot, exported)
}
private fun saveChunkSnapshot(snapshot: ChunkSnapshot, chunk: ExportedChunk) {
val file = File("exported_chunks/${snapshot.worldName}_chunk_${snapshot.x}_${snapshot.z}.json.gz")
if (!file.parentFile.exists()) {
file.parentFile.mkdirs()
}
val fileOutputStream = file.outputStream()
val gzipOutputStream = GZIPOutputStream(fileOutputStream)
json.encodeToStream(ExportedChunk.serializer(), chunk, gzipOutputStream)
gzipOutputStream.close()
}
private fun exportChunkSection(snapshot: ChunkSnapshot, yRange: IntRange, x: Int, z: Int): ExportedChunkSection {
val blocks = mutableListOf<ExportedBlock>()
for (y in yRange) {
val blockData = snapshot.getBlockData(x, y, z)
val block = ExportedBlock(blockData.material.key.toString())
blocks.add(block)
}
return ExportedChunkSection(x, z, blocks)
}
}

View File

@ -0,0 +1,17 @@
package gay.pizza.foundation.heimdall.plugin.export
import org.bukkit.command.Command
import org.bukkit.command.CommandExecutor
import org.bukkit.command.CommandSender
import org.bukkit.plugin.Plugin
class ExportChunksCommand(private val plugin: Plugin) : CommandExecutor {
override fun onCommand(sender: CommandSender, command: Command, label: String, args: Array<out String>): Boolean {
plugin.slF4JLogger.info("Exporting All Chunks")
for (world in sender.server.worlds) {
val export = ChunkExporter(plugin, world)
export.exportLoadedChunksAsync()
}
return true
}
}

View File

@ -0,0 +1,16 @@
package gay.pizza.foundation.heimdall.plugin.model
import kotlinx.serialization.Serializable
@Serializable
data class HeimdallConfig(
val enabled: Boolean = false,
val db: DbConfig
)
@Serializable
data class DbConfig(
val url: String,
val username: String,
val password: String
)

View File

@ -0,0 +1,11 @@
# Whether Heimdall should be enabled for tracking events.
enabled: false
# Database connection information.
db:
# JDBC URL
url: "jdbc:postgresql://localhost/heimdall"
# JDBC Username
username: "heimdall"
# JDBC Password
password: "heimdall"

View File

@ -0,0 +1,147 @@
create extension if not exists "uuid-ossp";
--
create extension if not exists timescaledb;
--
create schema if not exists heimdall;
--
create table if not exists player_positions (
time timestamp not null,
player uuid not null,
world uuid not null,
x double precision not null,
y double precision not null,
z double precision not null,
pitch double precision not null,
yaw double precision not null,
PRIMARY KEY (time, player, world)
);
--
select create_hypertable('player_positions', 'time', 'player', 4, if_not_exists => TRUE);
--
alter table player_positions set (
timescaledb.compress,
timescaledb.compress_segmentby = 'player,world',
timescaledb.compress_orderby = 'time'
);
--
select add_compression_policy('player_positions', interval '3 days', if_not_exists => true);
--
create table if not exists block_breaks (
time timestamp not null,
player uuid not null,
world uuid not null,
x double precision not null,
y double precision not null,
z double precision not null,
pitch double precision not null,
yaw double precision not null,
block text not null,
PRIMARY KEY (time, player, world)
);
--
select create_hypertable('block_breaks', 'time', 'player', 4, if_not_exists => TRUE);
--
create table if not exists block_places (
time timestamp not null,
player uuid not null,
world uuid not null,
x double precision not null,
y double precision not null,
z double precision not null,
pitch double precision not null,
yaw double precision not null,
block text not null,
PRIMARY KEY (time, player, world)
);
--
select create_hypertable('block_places', 'time', 'player', 4, if_not_exists => TRUE);
--
create table if not exists player_sessions (
id uuid not null,
player uuid not null,
name text not null,
"start" timestamp not null,
"end" timestamp not null,
primary key (id, player, start)
);
--
select create_hypertable('player_sessions', 'start', 'player', 4, if_not_exists => TRUE);
--
create table if not exists world_changes (
time timestamp not null,
player uuid not null,
from_world uuid not null,
from_world_name text not null,
to_world uuid not null,
to_world_name text not null,
primary key (time, player)
);
--
select create_hypertable('world_changes', 'time', 'player', 4, if_not_exists => TRUE);
--
create table if not exists player_deaths (
time timestamp not null,
player uuid not null,
world uuid not null,
x double precision not null,
y double precision not null,
z double precision not null,
pitch double precision not null,
yaw double precision not null,
experience double precision not null,
message text null,
primary key (time, player)
);
--
select create_hypertable('player_deaths', 'time', 'player', 4, if_not_exists => TRUE);
--
create table if not exists player_advancements (
time timestamp not null,
player uuid not null,
world uuid not null,
x double precision not null,
y double precision not null,
z double precision not null,
pitch double precision not null,
yaw double precision not null,
advancement text not null,
primary key (time, player, advancement)
);
--
select create_hypertable('player_advancements', 'time', 'player', 4, if_not_exists => TRUE);
--
create table if not exists entity_kills (
time timestamp not null,
player uuid not null,
entity uuid not null,
world uuid not null,
x double precision not null,
y double precision not null,
z double precision not null,
pitch double precision not null,
yaw double precision not null,
entity_type text not null,
primary key (time, entity, player)
);
--
select create_hypertable('entity_kills', 'time', 'player', 4, if_not_exists => TRUE);
--
create or replace view block_changes as
select true as break, *
from block_breaks
union all
select false as break, * from block_places;
--
create or replace view player_names as
with unique_player_ids as (
select distinct player
from player_sessions
)
select player, (
select name
from player_sessions
where player = unique_player_ids.player
order by "end" desc
limit 1
) as name
from unique_player_ids;

View File

@ -0,0 +1,13 @@
name: Heimdall
version: '${version}'
main: io.kexec.heimdall.plugin.HeimdallPlugin
api-version: 1.18
prefix: Heimdall
load: STARTUP
authors:
- kendfinger
commands:
export_all_chunks:
description: Export All Chunks
usage: /export_all_chunks
permission: heimdall.command.export_all_chunks

View File

@ -0,0 +1,64 @@
WITH
unique_player_ids AS (
SELECT
DISTINCT player
FROM player_sessions
),
player_names AS (
SELECT
player,
(
SELECT name
FROM 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 world_changes
),
world_names AS (
SELECT
world,
(
SELECT to_world_name
FROM world_changes
WHERE world = 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 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