package de.steamwar.commands.world import com.github.ajalt.clikt.core.CliktCommand import de.steamwar.db.Database import de.steamwar.sql.SteamwarUser import de.steamwar.sql.SteamwarWorld import de.steamwar.sql.UserConfig import org.jetbrains.exposed.v1.jdbc.transactions.transaction import java.io.File import java.sql.SQLException import java.util.UUID class MigrationCommand : CliktCommand(name = "migrate") { private val userWorldPattern = Regex("""userworlds(\d+)""") private val builderWorldPattern = Regex("""builder(\d+)""") private val trailingVersionPattern = Regex("""(\d+)$""") override fun run() { Database.ensureConnected() val bau = migrateBauWorlds() val builder = migrateBuilderWorlds() val arena = migrateArenaWorlds() migrateBauLockConfig() migrateBauweltMembers() echo("Imported ${bau.imported} build worlds, skipped ${bau.skipped}, missing users ${bau.missingOwner}.") echo("Imported ${builder.imported} builder worlds, skipped ${builder.skipped}.") echo("Imported ${arena.imported} arena worlds, skipped ${arena.skipped}.") echo("World migration completed.") } private fun migrateBauWorlds(): MigrationStats { val stats = MigrationStats() val worldsRoot = File("/worlds") worldsRoot.listFiles { file -> file.isDirectory && userWorldPattern.matches(file.name) } ?.forEach { versionFolder -> val version = userWorldPattern.matchEntire(versionFolder.name)!!.groupValues[1].toInt() versionFolder.listFiles { file -> file.isDirectory && isWorldDirectory(file) } ?.sortedBy { it.name } ?.forEach { legacyWorld -> val owner = legacyWorld.ownerUser() if (owner == null) { stats.missingOwner++ echo("Skipped build world ${legacyWorld.path}: no matching user.") return@forEach } val lockState = UserConfig.getConfig(owner.id.value, BAU_LOCK_CONFIG_NAME) val world = SteamwarWorld.getOrCreateBauWorld(owner, owner.userName, version, legacyWorld) if (world.lockState == null && lockState != null) { world.changeLockState(lockState) } stats.imported++ } } ?: run { stats.skipped++ echo("Skipped build worlds: /worlds does not exist.") } return stats } private fun migrateBuilderWorlds(): MigrationStats { val stats = MigrationStats() val worldsRoot = File("/worlds") worldsRoot.listFiles { file -> file.isDirectory && builderWorldPattern.matches(file.name) } ?.forEach { versionFolder -> val version = builderWorldPattern.matchEntire(versionFolder.name)!!.groupValues[1].toInt() versionFolder.listFiles { file -> file.isDirectory && isWorldDirectory(file) } ?.sortedBy { it.name } ?.forEach { legacyWorld -> SteamwarWorld.getOrCreateBuilderWorld(legacyWorld.name, version, legacyWorld) stats.imported++ } } ?: run { stats.skipped++ echo("Skipped builder worlds: /worlds does not exist.") } return stats } private fun migrateArenaWorlds(): MigrationStats { val stats = MigrationStats() val configs = parseGameModeConfigs(File("/configs/GameModes")) if (configs.isEmpty()) { stats.skipped++ echo("Skipped arena worlds: no game mode configs found in /configs/GameModes.") return stats } for (config in configs) { val version = trailingVersionPattern.find(config.serverFolder)?.value?.toIntOrNull() if (version == null) { stats.skipped++ echo("Skipped arena mode ${config.modeName}: server folder '${config.serverFolder}' has no version suffix.") continue } val arenaRoot = File("/servers/${config.serverFolder}/arenas") for (map in config.maps) { val legacyWorld = File(arenaRoot, map) if (!isWorldDirectory(legacyWorld)) { stats.skipped++ continue } SteamwarWorld.getOrCreateArenaWorld(config.modeName, map, version, legacyWorld) stats.imported++ } } return stats } private fun migrateBauLockConfig() { transaction(Database.db) { exec( """ UPDATE `world` w JOIN `UserConfig` uc ON uc.`User` = w.`Owner` AND uc.`Config` = 'baulockstate' SET w.`LockState` = uc.`Value` WHERE w.`Owner` IS NOT NULL AND w.`LockState` IS NULL """.trimIndent() ) } } private fun migrateBauweltMembers() { if (!columnExists("BauweltMember", "WorldID") || !columnExists("BauweltMember", "BauweltID")) { echo("Skipped BauweltMember data migration: expected WorldID and BauweltID columns are not both present.") return } execIgnore( """ INSERT IGNORE INTO `BauweltMember` (`WorldID`, `MemberID`, `Build`, `WorldEdit`, `World`) SELECT w.`id`, bm.`MemberID`, bm.`Build`, bm.`WorldEdit`, bm.`World` FROM `BauweltMember` bm JOIN `world` w ON w.`Owner` = bm.`BauweltID` AND w.`Deleted` = 0 """.trimIndent(), "Skipped BauweltMember data migration" ) } private fun parseGameModeConfigs(configRoot: File): List { if (!configRoot.isDirectory) return emptyList() return configRoot.listFiles { file -> file.isFile && file.extension == "yml" && !file.name.endsWith(".kits.yml") } ?.mapNotNull { file -> val server = parseServerBlock(file) val folder = server.folder ?: return@mapNotNull null ArenaMigrationConfig(file.nameWithoutExtension, folder, server.maps) } ?: emptyList() } private fun parseServerBlock(file: File): ParsedServerBlock { var inServer = false var inMaps = false var folder: String? = null val maps = mutableListOf() file.readLines().forEach { line -> val trimmed = line.trim() if (trimmed.isEmpty() || trimmed.startsWith("#")) return@forEach val indent = line.indexOfFirst { !it.isWhitespace() }.let { if (it == -1) 0 else it } if (indent == 0) { inServer = trimmed == "Server:" inMaps = false return@forEach } if (!inServer) return@forEach when { indent <= 2 && trimmed.startsWith("Folder:") -> folder = trimmed.substringAfter(":").cleanYamlValue() indent <= 2 && trimmed.startsWith("Maps:") -> { val value = trimmed.substringAfter(":").trim() inMaps = true if (value.startsWith("[") && value.endsWith("]")) { maps += value.removePrefix("[").removeSuffix("]") .split(",") .map { it.cleanYamlValue() } .filter { it.isNotEmpty() } inMaps = false } } inMaps && trimmed.startsWith("-") -> maps += trimmed.removePrefix("-").cleanYamlValue() inMaps && indent <= 2 -> inMaps = false } } return ParsedServerBlock(folder, maps) } private fun File.ownerUser(): SteamwarUser? { name.toIntOrNull()?.let { return SteamwarUser.byId(it) } return runCatching { SteamwarUser.get(UUID.fromString(name)) }.getOrNull() } private fun isWorldDirectory(file: File): Boolean = file.isDirectory && (File(file, "level.dat").isFile || File(file, "region").isDirectory) private fun String.cleanYamlValue(): String = trim().trim('"').trim('\'') private fun columnExists(table: String, column: String): Boolean = transaction(Database.db) { exec("SHOW COLUMNS FROM `$table` LIKE '$column'") { result -> result.next() } ?: false } private fun execIgnore(sql: String, prefix: String) { try { transaction(Database.db) { exec(sql) } } catch (e: SQLException) { echo("$prefix: ${e.message}") } } private data class MigrationStats( var imported: Int = 0, var skipped: Int = 0, var missingOwner: Int = 0, ) private data class ArenaMigrationConfig( val modeName: String, val serverFolder: String, val maps: List, ) private data class ParsedServerBlock( val folder: String?, val maps: List, ) private companion object { private const val BAU_LOCK_CONFIG_NAME = "baulockstate" } }