Implement world reassembly from a Heimdall backup.

This commit is contained in:
Alex Zenla 2023-02-07 09:01:43 -05:00
parent 192c6cb511
commit e8084d7283
Signed by: alex
GPG Key ID: C0780728420EBFE5
11 changed files with 209 additions and 6 deletions

View File

@ -0,0 +1,15 @@
package gay.pizza.foundation.heimdall.load
import gay.pizza.foundation.heimdall.export.ExportedBlock
import kotlinx.serialization.Serializable
@Serializable
class WorldLoadFormat(
val worlds: Map<String, WorldLoadWorld>
)
@Serializable
class WorldLoadWorld(
val name: String,
val blocks: Map<Long, Map<Long, Map<Long, ExportedBlock>>>
)

View File

@ -10,6 +10,7 @@ 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.export.ExportAllChunksCommand
import gay.pizza.foundation.heimdall.plugin.load.ImportWorldLoadCommand
import gay.pizza.foundation.heimdall.plugin.model.HeimdallConfig
import net.kyori.adventure.text.serializer.legacy.LegacyComponentSerializer
import org.bukkit.event.EventHandler
@ -45,6 +46,9 @@ class FoundationHeimdallPlugin : JavaPlugin(), Listener {
val exportChunksCommand = getCommand("export_all_chunks") ?: throw Exception("Failed to get export_all_chunks command")
exportChunksCommand.setExecutor(ExportAllChunksCommand(this))
val importWorldLoadCommand = getCommand("import_world_load") ?: throw Exception("Failed to get import_world_load command")
importWorldLoadCommand.setExecutor(ImportWorldLoadCommand(this))
val foundation = FoundationCoreLoader.get(server)
val configPath = copyDefaultConfig<FoundationHeimdallPlugin>(
slF4JLogger,

View File

@ -0,0 +1,37 @@
package gay.pizza.foundation.heimdall.plugin.load
import gay.pizza.foundation.heimdall.load.WorldLoadFormat
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.decodeFromStream
import org.bukkit.command.Command
import org.bukkit.command.CommandExecutor
import org.bukkit.command.CommandSender
import org.bukkit.plugin.Plugin
import java.nio.file.Paths
import kotlin.io.path.exists
import kotlin.io.path.inputStream
class ImportWorldLoadCommand(private val plugin: Plugin) : CommandExecutor {
override fun onCommand(
sender: CommandSender,
command: Command,
label: String,
args: Array<out String>
): Boolean {
if (args.size != 1) {
sender.sendMessage("Usage: import_world_load <path>")
return true
}
val pathString = args[0]
val path = Paths.get(pathString)
if (!path.exists()) {
sender.sendMessage("Path '${path}' not found.")
}
val format = Json.decodeFromStream(WorldLoadFormat.serializer(), path.inputStream())
val reassembler = WorldReassembler(plugin, sender.server, format) { message ->
sender.sendMessage(message)
}
reassembler.loadInBackground()
return true
}
}

View File

@ -0,0 +1,67 @@
package gay.pizza.foundation.heimdall.plugin.load
import gay.pizza.foundation.heimdall.load.WorldLoadFormat
import gay.pizza.foundation.heimdall.load.WorldLoadWorld
import org.bukkit.Location
import org.bukkit.Material
import org.bukkit.Server
import org.bukkit.plugin.Plugin
import org.bukkit.scheduler.BukkitRunnable
import java.util.concurrent.atomic.AtomicLong
class WorldReassembler(val plugin: Plugin, val server: Server, val format: WorldLoadFormat, val feedback: (String) -> Unit) {
fun loadInBackground() {
server.scheduler.runTaskAsynchronously(plugin) { ->
for (world in server.worlds) {
val id = world.uid
var load: WorldLoadWorld? = format.worlds[id.toString().lowercase()]
if (load == null) {
load = format.worlds.values.firstOrNull { it.name == world.name }
}
if (load == null) {
feedback("Unable to match world ${world.uid} (${world.name}) to a loadable world, skipping")
continue
}
val blocksToMake = mutableListOf<Pair<Location, Material>>()
for ((x, zBlocks) in load.blocks) {
for ((z, yBlocks) in zBlocks) {
for ((y, block) in yBlocks) {
val material: Material? = Material.matchMaterial(block.type)
if (material == null) {
feedback("Unknown Material '${block.type}' at $x $y $z")
continue
}
blocksToMake.add(Location(world, x.toDouble(), y.toDouble(), z.toDouble()) to material)
}
}
}
blocksToMake.sortBy { it.first.x }
feedback("Will place ${blocksToMake.size} blocks in ${world.name}")
val count = AtomicLong()
var ticks = 0L
blocksToMake.chunked(1000) { section ->
val copy = section.toList()
val runnable = object : BukkitRunnable() {
override fun run() {
for ((location, material) in copy) {
val block = world.getBlockAt(location)
block.type = material
count.incrementAndGet()
}
feedback("Placed ${count.get()} blocks in ${world.name}")
}
}
runnable.runTaskLater(plugin, ticks)
ticks += 10
}
}
}
}
}

View File

@ -13,3 +13,7 @@ commands:
description: Export All Chunks
usage: /export_all_chunks
permission: heimdall.command.export_all_chunks
import_world_load:
description: Import World Load
usage: /import_world_load
permission: heimdall.command.import_world_load

View File

@ -30,6 +30,7 @@ class GjallarhornCommand : CliktCommand(invokeWithoutSubcommand = true) {
maximumPoolSize = dbPoolSize
idleTimeout = Duration.ofMinutes(5).toMillis()
maxLifetime = Duration.ofMinutes(10).toMillis()
schema = "heimdall"
})
val db = Database.connect(pool)
currentContext.findOrSetObject { db }

View File

@ -0,0 +1,50 @@
package gay.pizza.foundation.heimdall.tool.commands
import com.github.ajalt.clikt.core.CliktCommand
import com.github.ajalt.clikt.core.requireObject
import com.github.ajalt.clikt.parameters.arguments.argument
import com.github.ajalt.clikt.parameters.types.path
import gay.pizza.foundation.heimdall.export.ExportedBlock
import gay.pizza.foundation.heimdall.load.WorldLoadFormat
import gay.pizza.foundation.heimdall.load.WorldLoadWorld
import gay.pizza.foundation.heimdall.table.WorldChangeTable
import gay.pizza.foundation.heimdall.tool.state.BlockChangelog
import gay.pizza.foundation.heimdall.tool.state.BlockLogTracker
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.encodeToStream
import org.jetbrains.exposed.sql.Database
import org.jetbrains.exposed.sql.selectAll
import org.jetbrains.exposed.sql.transactions.transaction
import org.slf4j.LoggerFactory
import kotlin.io.path.createFile
import kotlin.io.path.deleteExisting
import kotlin.io.path.deleteIfExists
import kotlin.io.path.outputStream
class GenerateWorldLoadFile : CliktCommand(name = "generate-world-load", help = "Generate World Load File") {
private val db by requireObject<Database>()
val path by argument("load-format-file").path()
override fun run() {
val worlds = mutableMapOf<String, WorldLoadWorld>()
val worldChangelogs = BlockChangelog.query(db).splitBy { it.world }
val worldNames = transaction(db) {
WorldChangeTable.selectAll()
.associate { it[WorldChangeTable.toWorld] to it[WorldChangeTable.toWorldName] }
}
for ((id, changelog) in worldChangelogs) {
val tracker = BlockLogTracker()
tracker.replay(changelog)
val sparse = tracker.buildBlockMap { ExportedBlock(it.type) }
val blocks = sparse.blocks
worlds[id.toString().lowercase()] = WorldLoadWorld(
worldNames[id] ?: "unknown_$id",
blocks
)
}
val format = WorldLoadFormat(worlds)
path.deleteIfExists()
Json.encodeToStream(format, path.outputStream())
}
}

View File

@ -1,14 +1,12 @@
package gay.pizza.foundation.heimdall.tool
import gay.pizza.foundation.heimdall.tool.commands.BlockChangeTimelapseCommand
import gay.pizza.foundation.heimdall.tool.commands.ChunkExportLoaderCommand
import gay.pizza.foundation.heimdall.tool.commands.PlayerPositionExport
import gay.pizza.foundation.heimdall.tool.commands.PlayerSessionExport
import com.github.ajalt.clikt.core.subcommands
import gay.pizza.foundation.heimdall.tool.commands.*
fun main(args: Array<String>) = GjallarhornCommand().subcommands(
BlockChangeTimelapseCommand(),
PlayerSessionExport(),
PlayerPositionExport(),
ChunkExportLoaderCommand()
ChunkExportLoaderCommand(),
GenerateWorldLoadFile()
).main(args)

View File

@ -1,9 +1,11 @@
package gay.pizza.foundation.heimdall.tool.state
import java.time.Instant
import java.util.UUID
data class BlockChange(
val time: Instant,
val world: UUID,
val type: BlockChangeType,
val location: BlockCoordinate,
val from: BlockState,

View File

@ -63,10 +63,11 @@ class BlockChangelog(
BlockChangelog(BlockChangeView.select(filter).orderBy(BlockChangeView.time).map { row ->
val time = row[BlockChangeView.time]
val changeIsBreak = row[BlockChangeView.isBreak]
val world = row[BlockChangeView.world]
val x = row[BlockChangeView.x]
val y = row[BlockChangeView.y]
val z = row[BlockChangeView.z]
val block = row[gay.pizza.foundation.heimdall.view.BlockChangeView.block]
val block = row[BlockChangeView.block]
val location = BlockCoordinate(x.toLong(), y.toLong(), z.toLong())
val fromBlock = if (changeIsBreak) {
@ -83,6 +84,7 @@ class BlockChangelog(
BlockChange(
time,
world,
if (changeIsBreak) BlockChangeType.Break else BlockChangeType.Place,
location,
fromBlock,
@ -91,4 +93,18 @@ class BlockChangelog(
})
}
}
fun <T> splitBy(key: (BlockChange) -> T): Map<T, BlockChangelog> {
val logs = mutableMapOf<T, MutableList<BlockChange>>()
for (change in changes) {
val k = key(change)
var log = logs[k]
if (log == null) {
log = mutableListOf()
logs[k] = log
}
log.add(change)
}
return logs.mapValues { BlockChangelog(it.value) }
}
}

View File

@ -50,6 +50,15 @@ class BlockLogTracker(private val mode: BlockTrackMode = BlockTrackMode.RemoveOn
return map
}
fun <T> buildBlockMap(offset: BlockCoordinate = BlockCoordinate.zero, value: (BlockState) -> T): BlockCoordinateSparseMap<T> {
val map = BlockCoordinateSparseMap<T>()
blocks.forEach { (position, state) ->
val realPosition = offset.applyAsOffset(position)
map.put(realPosition, value(state))
}
return map
}
fun replay(changelog: BlockChangelog) = changelog.changes.forEach { change ->
if (change.type == BlockChangeType.Break) {
delete(change.location)