forked from SteamWar/SteamWar
- Switch bau logic from owner-scoped to world-scoped data - Add CLI commands for world migration, templating, and archiving - Extend world storage with team worlds and lock state support
244 lines
9.2 KiB
Kotlin
244 lines
9.2 KiB
Kotlin
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<ArenaMigrationConfig> {
|
|
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<String>()
|
|
|
|
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<String>,
|
|
)
|
|
|
|
private data class ParsedServerBlock(
|
|
val folder: String?,
|
|
val maps: List<String>,
|
|
)
|
|
|
|
private companion object {
|
|
private const val BAU_LOCK_CONFIG_NAME = "baulockstate"
|
|
}
|
|
}
|