feat: マイグレーションを作成

This commit is contained in:
2025-11-27 03:48:19 +09:00
parent f497ef1ee2
commit 3be9a59370
13 changed files with 211 additions and 79 deletions
@@ -18,10 +18,12 @@ public class App : JavaPlugin() {
field = value
Config.config.set("enabled", !value.equals(State.DISABLED))
}
companion object {
lateinit var instance: App
private set
}
lateinit var command: KommandLib
private set
@@ -34,6 +36,7 @@ public class App : JavaPlugin() {
if (Config.check()) enable()
}
override fun onDisable() {
enabled = State.DISABLED
Database.disconnect()
@@ -72,6 +72,91 @@ public val smcdb =
sender.sendMessage("database reset.")
},
),
Route("migrate") { sender, _ ->
if (sender !is Player) {
sender.sendMM("<red>[SMCDB] This command can only be run by players.")
return@Route
}
when (App.instance.enabled) {
State.DISABLED -> {
sender.sendMM("<red>[SMCDB] simplymcdb is disabled.")
return@Route
}
State.DISCONNECTED -> {
sender.sendMM("<yellow>[SMCDB] Database disconnected. Try again later.")
return@Route
}
else -> {}
}
if (!isRegistered(sender.uniqueId)) {
sender.sendMM("<red>[SMCDB] You are not registered in the database.")
return@Route
}
try {
sender.sendMM("<gray>[SMCDB] Applying legacy data...")
fetch(sender)
update(sender)
sender.sendMM("<green>[SMCDB] Migration complete. Data updated to the latest format.")
} catch (e: Exception) {
App.instance.logger.warning("Failed to migrate data for ${sender.uniqueId}: ${e.message}")
sender.sendMM("<red>[SMCDB] Migration failed. Check server logs.")
}
}.addArgs(
Route("all") { sender, _ ->
if (sender !is Player) {
sender.sendMM("<red>[SMCDB] This command can only be run by players.")
return@Route
}
when (App.instance.enabled) {
State.DISABLED -> {
sender.sendMM("<red>[SMCDB] simplymcdb is disabled.")
return@Route
}
State.DISCONNECTED -> {
sender.sendMM(
"<yellow>[SMCDB] Database disconnected. Try again later."
)
return@Route
}
else -> {}
}
val targets = findPlayersNeedingMigration()
if (targets.isEmpty()) {
sender.sendMM("<gray>[SMCDB] No legacy data found.")
return@Route
}
sender.sendMM(
"<gray>[SMCDB] Migrating ${targets.size} legacy profiles... Please wait."
)
val backup = PlayerSerializer.serialize(sender)
var migrated = 0
try {
targets.forEach { entry ->
try {
PlayerSerializer.deserialize(sender, entry.serialized)
val updatedSnapshot = PlayerSerializer.serialize(sender)
overwritePlayerData(entry.uuid, updatedSnapshot)
migrated++
} catch (ex: Exception) {
App.instance.logger.warning(
"Failed to migrate data for ${entry.uuid}: ${ex.message}"
)
}
}
} finally {
try {
PlayerSerializer.deserialize(sender, backup)
} catch (restoreEx: Exception) {
App.instance.logger.warning(
"Failed to restore migration executor state: ${restoreEx.message}"
)
}
}
sender.sendMM(
"<green>[SMCDB] Migration finished ($migrated/${targets.size}). Check logs for failures."
)
}
),
Route("check") { sender, _ ->
sender.sendMM(
"${when (App.instance.enabled) {
@@ -1,24 +1,26 @@
package net.hareworks.simplymcdb
import de.tr7zw.nbtapi.NBT
import java.io.ByteArrayInputStream
import java.io.ByteArrayOutputStream
import java.util.Base64
import java.util.function.Function
import kotlinx.serialization.SerialName
import io.papermc.paper.registry.RegistryAccess
import io.papermc.paper.registry.RegistryKey
import kotlinx.serialization.Serializable
import kotlinx.serialization.SerializationException
import kotlinx.serialization.json.Json
import org.bukkit.NamespacedKey
import org.bukkit.Registry
import org.bukkit.attribute.Attribute
import org.bukkit.entity.Player as BukkitPlayer
import org.bukkit.inventory.ItemStack
import org.bukkit.potion.PotionEffect
import org.bukkit.potion.PotionEffectType
import org.bukkit.util.io.BukkitObjectInputStream
import org.bukkit.util.io.BukkitObjectOutputStream
const val PLAYER_DATA_CURRENT_VERSION = 1
@Serializable
data class PlayerSnapshot(
val version: Int = CURRENT_VERSION,
val version: Int = PLAYER_DATA_CURRENT_VERSION,
val health: Double,
val foodLevel: Int,
val xpProgress: Float,
@@ -38,15 +40,15 @@ data class PotionEffectSnapshot(
val icon: Boolean
)
@Serializable
data class ItemStackSnapshot(@SerialName("value") val encodedItem: String)
@Serializable data class ItemStackSnapshot(val payload: String)
private const val CURRENT_VERSION = 1
private val json =
Json {
encodeDefaults = true
ignoreUnknownKeys = true
}
private val mobEffectRegistry: Registry<PotionEffectType>?
get() = RegistryAccess.registryAccess().getRegistry(RegistryKey.MOB_EFFECT)
object PlayerSerializer {
fun serialize(player: BukkitPlayer): String {
@@ -67,10 +69,10 @@ object PlayerSerializer {
val snapshot =
try {
json.decodeFromString(PlayerSnapshot.serializer(), data)
} catch (_: SerializationException) {
return LegacySerializer.deserialize(player, data)
} catch (_: IllegalArgumentException) {
return LegacySerializer.deserialize(player, data)
} catch (ex: SerializationException) {
if (LegacySerializer.deserialize(player, data)) return else throw ex
} catch (ex: IllegalArgumentException) {
if (LegacySerializer.deserialize(player, data)) return else throw ex
}
applySnapshot(player, migrateIfNeeded(snapshot))
}
@@ -78,7 +80,7 @@ object PlayerSerializer {
private fun migrateIfNeeded(snapshot: PlayerSnapshot): PlayerSnapshot {
var current = snapshot
var version = snapshot.version
while (version < CURRENT_VERSION) {
while (version < PLAYER_DATA_CURRENT_VERSION) {
current = migrateOnce(version, current)
version++
}
@@ -93,18 +95,19 @@ object PlayerSerializer {
}
private fun applySnapshot(player: BukkitPlayer, snapshot: PlayerSnapshot) {
player.health = snapshot.health.coerceIn(0.0, player.maxHealth)
val maxHealth = player.getAttribute(Attribute.MAX_HEALTH)?.value ?: player.health
player.health = snapshot.health.coerceIn(0.0, maxHealth)
player.foodLevel = snapshot.foodLevel.coerceIn(0, 20)
player.exp = snapshot.xpProgress.coerceIn(0f, 1f)
player.inventory.heldItemSlot =
snapshot.selectedItemSlot.coerceIn(0, player.inventory.contents.size - 1)
val appliedTypes = player.activePotionEffects.map { it.type }.toSet()
appliedTypes.forEach { it?.let(player::removePotionEffect) }
player.activePotionEffects.forEach { player.removePotionEffect(it.type) }
snapshot.potionEffects.forEach { eff ->
val type = PotionEffectType.getByName(eff.type)
val typeKey = NamespacedKey.fromString(eff.type)
val type = typeKey?.let { key -> mobEffectRegistry?.get(key) }
if (type == null) {
App.instance.logger.warning("Unknown potion effect type during restore: ${eff.type}")
App.instance.logger.warning("Unknown potion effect key during restore: ${eff.type}")
return@forEach
}
val potion = PotionEffect(type, eff.duration, eff.amplifier, eff.ambient, eff.particles, eff.icon)
@@ -124,8 +127,9 @@ object PlayerSerializer {
}
private fun serializePotionEffect(effect: PotionEffect): PotionEffectSnapshot {
val typeKey = effect.type.key().toString()
return PotionEffectSnapshot(
type = effect.type.name ?: "",
type = typeKey,
amplifier = effect.amplifier,
duration = effect.duration,
ambient = effect.isAmbient,
@@ -135,42 +139,41 @@ private fun serializePotionEffect(effect: PotionEffect): PotionEffectSnapshot {
}
private fun serializeItemStack(item: ItemStack): ItemStackSnapshot {
val byteArray =
ByteArrayOutputStream().use { byteStream ->
BukkitObjectOutputStream(byteStream).use { out -> out.writeObject(item) }
byteStream.toByteArray()
}
return ItemStackSnapshot(Base64.getEncoder().encodeToString(byteArray))
val bytes = item.ensureServerConversions().serializeAsBytes()
return ItemStackSnapshot(Base64.getEncoder().encodeToString(bytes))
}
private fun deserializeItemStack(snapshot: ItemStackSnapshot): ItemStack {
val data = Base64.getDecoder().decode(snapshot.encodedItem)
return ByteArrayInputStream(data).use { byteStream ->
BukkitObjectInputStream(byteStream).use { input -> input.readObject() as ItemStack }
}
val data = Base64.getDecoder().decode(snapshot.payload)
return ItemStack.deserializeBytes(data)
}
private object LegacySerializer {
fun deserialize(player: BukkitPlayer, data: String) {
NBT.modify(
player,
Function { nbt ->
val input = NBT.parseNBT(data)
nbt.setFloat("Health", input.getFloat("Health"))
nbt.setInteger("foodLevel", input.getInteger("foodLevel"))
nbt.setFloat("XpP", input.getFloat("XpP"))
nbt.setInteger("SelectedItemSlot", input.getInteger("SelectedItemSlot"))
val activeEffects = nbt.getCompoundList("active_effects")
activeEffects.clear()
input.getCompoundList("active_effects").forEach { activeEffects.addCompound(it) }
val inventory = nbt.getCompoundList("Inventory")
inventory.clear()
input.getCompoundList("Inventory").forEach { inventory.addCompound(it) }
val enderchest = nbt.getCompoundList("EnderItems")
enderchest.clear()
input.getCompoundList("EnderItems").forEach { enderchest.addCompound(it) }
}
)
App.instance.logger.info("Legacy player data applied; will be migrated on next save.")
fun deserialize(player: BukkitPlayer, data: String): Boolean {
return try {
NBT.modify(
player,
Function { nbt ->
val input = NBT.parseNBT(data)
nbt.setFloat("Health", input.getFloat("Health"))
nbt.setInteger("foodLevel", input.getInteger("foodLevel"))
nbt.setFloat("XpP", input.getFloat("XpP"))
nbt.setInteger("SelectedItemSlot", input.getInteger("SelectedItemSlot"))
val activeEffects = nbt.getCompoundList("active_effects")
activeEffects.clear()
input.getCompoundList("active_effects").forEach { activeEffects.addCompound(it) }
val inventory = nbt.getCompoundList("Inventory")
inventory.clear()
input.getCompoundList("Inventory").forEach { inventory.addCompound(it) }
val enderchest = nbt.getCompoundList("EnderItems")
enderchest.clear()
input.getCompoundList("EnderItems").forEach { enderchest.addCompound(it) }
}
)
App.instance.logger.info("Legacy player data applied; will be migrated on next save.")
true
} catch (_: Exception) {
false
}
}
}
@@ -17,6 +17,7 @@ public object Players : Table() {
val lastIp = varchar("last_ip", 15)
val data = text("data").default("")
val dataVersion = integer("data_version").default(0)
override val primaryKey = PrimaryKey(uuid)
}
@@ -39,6 +40,7 @@ public fun register(player: BukkitPlayer) {
it[firstLogin] = System.currentTimeMillis()
it[lastOnline] = System.currentTimeMillis()
it[lastIp] = player.address?.address?.hostAddress ?: "unknown"
it[dataVersion] = 0
}
}
}
@@ -52,6 +54,7 @@ public fun update(player: BukkitPlayer) {
// player.sendMessage(dat)
it[data] = dat
it[dataVersion] = PLAYER_DATA_CURRENT_VERSION
}
}
}
@@ -68,3 +71,25 @@ public fun fetch(player: BukkitPlayer) {
// player.sendMessage(dat)
PlayerSerializer.deserialize(player, dat)
}
data class PlayerDataEntry(val uuid: UUID, val serialized: String, val version: Int)
public fun findPlayersNeedingMigration(): List<PlayerDataEntry> {
return transaction(Database.instance) {
Players
.selectAll()
.where { (Players.dataVersion less PLAYER_DATA_CURRENT_VERSION) and (Players.data neq "") }
.map {
PlayerDataEntry(UUID.fromString(it[Players.uuid]), it[Players.data], it[Players.dataVersion])
}
}
}
public fun overwritePlayerData(uuid: UUID, data: String, version: Int = PLAYER_DATA_CURRENT_VERSION) {
transaction(Database.instance) {
Players.update({ Players.uuid eq uuid.toString() }) {
it[Players.data] = data
it[Players.dataVersion] = version
}
}
}
@@ -51,6 +51,7 @@ public object Database {
}
if (instance == null) return
App.instance.logger.info("Database connected: $host:$port/$database")
transaction(instance) { SchemaUtils.createMissingTablesAndColumns(Players) }
}
public fun disconnect() {
instance?.let {