diff --git a/.gitignore b/.gitignore index 916be9d..84c9395 100644 --- a/.gitignore +++ b/.gitignore @@ -21,4 +21,5 @@ gradle-app.setting # JDT-specific (Eclipse Java Development Tools) .classpath -.direnv \ No newline at end of file +.direnv +bin \ No newline at end of file diff --git a/build.gradle.kts b/build.gradle.kts index 17de1e8..2957424 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -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") diff --git a/flake.nix b/flake.nix index 16e047d..7f61bf2 100644 --- a/flake.nix +++ b/flake.nix @@ -25,6 +25,7 @@ packages = with pkgs; [ jdk21 gradle + kotlin git unzip ]; diff --git a/gradle/gradle-daemon-jvm.properties b/gradle/gradle-daemon-jvm.properties new file mode 100644 index 0000000..63e5bbd --- /dev/null +++ b/gradle/gradle-daemon-jvm.properties @@ -0,0 +1,2 @@ +#This file is generated by updateDaemonJvm +toolchainVersion=21 diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar index ccebba7..a4b76b9 100644 Binary files a/gradle/wrapper/gradle-wrapper.jar and b/gradle/wrapper/gradle-wrapper.jar differ diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index c44c230..ca025c8 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -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 diff --git a/gradlew b/gradlew index 79a61d4..f5feea6 100755 --- a/gradlew +++ b/gradlew @@ -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,10 +134,13 @@ 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. @@ -144,7 +148,7 @@ 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" \ diff --git a/gradlew.bat b/gradlew.bat index 6689b85..9b42019 100644 --- a/gradlew.bat +++ b/gradlew.bat @@ -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 diff --git a/src/main/kotlin/net/hareworks/simplymcdb/App.kt b/src/main/kotlin/net/hareworks/simplymcdb/App.kt index fc79b4a..37de7c7 100644 --- a/src/main/kotlin/net/hareworks/simplymcdb/App.kt +++ b/src/main/kotlin/net/hareworks/simplymcdb/App.kt @@ -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() diff --git a/src/main/kotlin/net/hareworks/simplymcdb/Command.kt b/src/main/kotlin/net/hareworks/simplymcdb/Command.kt index 7e94cb8..8ff5766 100644 --- a/src/main/kotlin/net/hareworks/simplymcdb/Command.kt +++ b/src/main/kotlin/net/hareworks/simplymcdb/Command.kt @@ -72,6 +72,91 @@ public val smcdb = sender.sendMessage("database reset.") }, ), + Route("migrate") { sender, _ -> + if (sender !is Player) { + sender.sendMM("[SMCDB] This command can only be run by players.") + return@Route + } + when (App.instance.enabled) { + State.DISABLED -> { + sender.sendMM("[SMCDB] simplymcdb is disabled.") + return@Route + } + State.DISCONNECTED -> { + sender.sendMM("[SMCDB] Database disconnected. Try again later.") + return@Route + } + else -> {} + } + if (!isRegistered(sender.uniqueId)) { + sender.sendMM("[SMCDB] You are not registered in the database.") + return@Route + } + try { + sender.sendMM("[SMCDB] Applying legacy data...") + fetch(sender) + update(sender) + sender.sendMM("[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("[SMCDB] Migration failed. Check server logs.") + } + }.addArgs( + Route("all") { sender, _ -> + if (sender !is Player) { + sender.sendMM("[SMCDB] This command can only be run by players.") + return@Route + } + when (App.instance.enabled) { + State.DISABLED -> { + sender.sendMM("[SMCDB] simplymcdb is disabled.") + return@Route + } + State.DISCONNECTED -> { + sender.sendMM( + "[SMCDB] Database disconnected. Try again later." + ) + return@Route + } + else -> {} + } + val targets = findPlayersNeedingMigration() + if (targets.isEmpty()) { + sender.sendMM("[SMCDB] No legacy data found.") + return@Route + } + sender.sendMM( + "[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( + "[SMCDB] Migration finished ($migrated/${targets.size}). Check logs for failures." + ) + } + ), Route("check") { sender, _ -> sender.sendMM( "${when (App.instance.enabled) { diff --git a/src/main/kotlin/net/hareworks/simplymcdb/PlayerSerializer.kt b/src/main/kotlin/net/hareworks/simplymcdb/PlayerSerializer.kt index 4613528..067530b 100644 --- a/src/main/kotlin/net/hareworks/simplymcdb/PlayerSerializer.kt +++ b/src/main/kotlin/net/hareworks/simplymcdb/PlayerSerializer.kt @@ -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? + 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 + } } } diff --git a/src/main/kotlin/net/hareworks/simplymcdb/Players.kt b/src/main/kotlin/net/hareworks/simplymcdb/Players.kt index 3dbb01b..689f619 100644 --- a/src/main/kotlin/net/hareworks/simplymcdb/Players.kt +++ b/src/main/kotlin/net/hareworks/simplymcdb/Players.kt @@ -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 { + 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 + } + } +} diff --git a/src/main/kotlin/net/hareworks/simplymcdb/database/Database.kt b/src/main/kotlin/net/hareworks/simplymcdb/database/Database.kt index 4c154b9..7776045 100644 --- a/src/main/kotlin/net/hareworks/simplymcdb/database/Database.kt +++ b/src/main/kotlin/net/hareworks/simplymcdb/database/Database.kt @@ -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 {