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

This commit is contained in:
Keisuke Hirata 2025-11-27 03:48:19 +09:00
parent f497ef1ee2
commit 3be9a59370
13 changed files with 211 additions and 79 deletions

1
.gitignore vendored
View File

@ -22,3 +22,4 @@ gradle-app.setting
.classpath
.direnv
bin

View File

@ -17,7 +17,7 @@ repositories {
maven("https://repo.codemc.io/repository/maven-public/")
}
dependencies {
compileOnly("io.papermc.paper:paper-api:1.21.3-R0.1-SNAPSHOT")
compileOnly("io.papermc.paper:paper-api:1.21.10-R0.1-SNAPSHOT")
implementation("net.kyori:adventure-api:4.17.0")
implementation("net.kyori:adventure-text-minimessage:4.17.0")
implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.7.1")
@ -27,7 +27,7 @@ dependencies {
implementation("org.jetbrains.exposed:exposed-jdbc:$exposedVersion")
implementation("org.jetbrains.kotlinx:kotlinx-datetime:0.6.1")
implementation("org.jetbrains.exposed:exposed-kotlin-datetime:$exposedVersion")
compileOnly("de.tr7zw:item-nbt-api-plugin:2.14.0")
compileOnly("de.tr7zw:item-nbt-api-plugin:2.15.3")
implementation("com.michael-bull.kotlin-result:kotlin-result:2.0.0")
}
tasks {
@ -42,7 +42,7 @@ bukkit {
name = "Simply-Minecraft-DB"
description = "It provides a simple way to manage player data through a database."
version = getVersion().toString()
apiVersion = "1.21.3"
apiVersion = "1.21.10"
authors =
listOf("Hare-K02")
depend = listOf("NBTAPI")

View File

@ -25,6 +25,7 @@
packages = with pkgs; [
jdk21
gradle
kotlin
git
unzip
];

View File

@ -0,0 +1,2 @@
#This file is generated by updateDaemonJvm
toolchainVersion=21

Binary file not shown.

View File

@ -1,6 +1,7 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-8.10.1-bin.zip
distributionUrl=https\://services.gradle.org/distributions/gradle-8.14-bin.zip
networkTimeout=10000
validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists

34
gradlew vendored
View File

@ -15,6 +15,8 @@
# See the License for the specific language governing permissions and
# limitations under the License.
#
# SPDX-License-Identifier: Apache-2.0
#
##############################################################################
#
@ -55,7 +57,7 @@
# Darwin, MinGW, and NonStop.
#
# (3) This script is generated from the Groovy template
# https://github.com/gradle/gradle/blob/HEAD/subprojects/plugins/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
# within the Gradle project.
#
# You can find Gradle at https://github.com/gradle/gradle/.
@ -83,10 +85,9 @@ done
# This is normally unused
# shellcheck disable=SC2034
APP_BASE_NAME=${0##*/}
APP_HOME=$( cd "${APP_HOME:-./}" && pwd -P ) || exit
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s
' "$PWD" ) || exit
# Use the maximum available, or set MAX_FD != -1 to use that value.
MAX_FD=maximum
@ -133,18 +134,21 @@ location of your Java installation."
fi
else
JAVACMD=java
which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
if ! command -v java >/dev/null 2>&1
then
die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
fi
# Increase the maximum file descriptors if we can.
if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
case $MAX_FD in #(
max*)
# In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
# shellcheck disable=SC3045
# shellcheck disable=SC2039,SC3045
MAX_FD=$( ulimit -H -n ) ||
warn "Could not query maximum file descriptor limit"
esac
@ -152,7 +156,7 @@ if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
'' | soft) :;; #(
*)
# In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
# shellcheck disable=SC3045
# shellcheck disable=SC2039,SC3045
ulimit -n "$MAX_FD" ||
warn "Could not set maximum file descriptor limit to $MAX_FD"
esac
@ -197,11 +201,15 @@ if "$cygwin" || "$msys" ; then
done
fi
# Collect all arguments for the java command;
# * $DEFAULT_JVM_OPTS, $JAVA_OPTS, and $GRADLE_OPTS can contain fragments of
# shell script including quotes and variable substitutions, so put them in
# double quotes to make sure that they get re-expanded; and
# * put everything else in single quotes, so that it's not re-expanded.
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
# Collect all arguments for the java command:
# * DEFAULT_JVM_OPTS, JAVA_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments,
# and any embedded shellness will be escaped.
# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be
# treated as '${Hostname}' itself on the command line.
set -- \
"-Dorg.gradle.appname=$APP_BASE_NAME" \

22
gradlew.bat vendored
View File

@ -13,6 +13,8 @@
@rem See the License for the specific language governing permissions and
@rem limitations under the License.
@rem
@rem SPDX-License-Identifier: Apache-2.0
@rem
@if "%DEBUG%"=="" @echo off
@rem ##########################################################################
@ -43,11 +45,11 @@ set JAVA_EXE=java.exe
%JAVA_EXE% -version >NUL 2>&1
if %ERRORLEVEL% equ 0 goto execute
echo.
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
echo.
echo Please set the JAVA_HOME variable in your environment to match the
echo location of your Java installation.
echo. 1>&2
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2
echo. 1>&2
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
echo location of your Java installation. 1>&2
goto fail
@ -57,11 +59,11 @@ set JAVA_EXE=%JAVA_HOME%/bin/java.exe
if exist "%JAVA_EXE%" goto execute
echo.
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME%
echo.
echo Please set the JAVA_HOME variable in your environment to match the
echo location of your Java installation.
echo. 1>&2
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2
echo. 1>&2
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
echo location of your Java installation. 1>&2
goto fail

View File

@ -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()

View File

@ -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) {

View File

@ -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,23 +139,18 @@ 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) {
fun deserialize(player: BukkitPlayer, data: String): Boolean {
return try {
NBT.modify(
player,
Function { nbt ->
@ -172,5 +171,9 @@ private object LegacySerializer {
}
)
App.instance.logger.info("Legacy player data applied; will be migrated on next save.")
true
} catch (_: Exception) {
false
}
}
}

View File

@ -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
}
}
}

View File

@ -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 {